diff --git a/src/crewai/cli/cli.py b/src/crewai/cli/cli.py index c0eff594c..1d9dc9f80 100644 --- a/src/crewai/cli/cli.py +++ b/src/crewai/cli/cli.py @@ -16,6 +16,7 @@ from .deploy.main import DeployCommand from .evaluate_crew import evaluate_crew from .install_crew import install_crew from .kickoff_flow import kickoff_flow +from .organization.main import OrganizationCommand from .plot_flow import plot_flow from .replay_from_task import replay_task_command from .reset_memories_command import reset_memories_command @@ -353,5 +354,33 @@ def chat(): run_chat() +@crewai.group(invoke_without_command=True) +def org(): + """Organization management commands.""" + pass + + +@org.command() +def list(): + """List available organizations.""" + org_command = OrganizationCommand() + org_command.list() + + +@org.command() +@click.argument("handle") +def switch(handle): + """Switch to a specific organization.""" + org_command = OrganizationCommand() + org_command.switch(handle) + + +@org.command() +def current(): + """Show current organization when 'crewai org' is called without subcommands.""" + org_command = OrganizationCommand() + org_command.current() + + if __name__ == "__main__": crewai() diff --git a/src/crewai/cli/config.py b/src/crewai/cli/config.py index 8e30767ca..5d363f16c 100644 --- a/src/crewai/cli/config.py +++ b/src/crewai/cli/config.py @@ -14,6 +14,12 @@ class Settings(BaseModel): tool_repository_password: Optional[str] = Field( None, description="Password for interacting with the Tool Repository" ) + org_name: Optional[str] = Field( + None, description="Name of the currently active organization" + ) + org_uuid: Optional[str] = Field( + None, description="UUID of the currently active organization" + ) config_path: Path = Field(default=DEFAULT_CONFIG_PATH, exclude=True) def __init__(self, config_path: Path = DEFAULT_CONFIG_PATH, **data): diff --git a/src/crewai/cli/organization/__init__.py b/src/crewai/cli/organization/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/crewai/cli/organization/__init__.py @@ -0,0 +1 @@ + diff --git a/src/crewai/cli/organization/main.py b/src/crewai/cli/organization/main.py new file mode 100644 index 000000000..ec4146089 --- /dev/null +++ b/src/crewai/cli/organization/main.py @@ -0,0 +1,63 @@ +from rich.console import Console +from rich.table import Table + +from crewai.cli.command import BaseCommand, PlusAPIMixin +from crewai.cli.config import Settings + +console = Console() + +class OrganizationCommand(BaseCommand, PlusAPIMixin): + def __init__(self): + BaseCommand.__init__(self) + PlusAPIMixin.__init__(self, telemetry=self._telemetry) + + def list(self): + try: + response = self.plus_api_client.get_organizations() + response.raise_for_status() + orgs = response.json() + + if not orgs: + console.print("You don't belong to any organizations yet.", style="yellow") + return + + table = Table(title="Your Organizations") + table.add_column("Name", style="cyan") + table.add_column("Handle", style="green") + for org in orgs: + table.add_row(org["name"], org["uuid"]) + + console.print(table) + except Exception as e: + console.print(f"Failed to retrieve organization list: {str(e)}", style="bold red") + raise SystemExit(1) + + def switch(self, org_handle): + try: + response = self.plus_api_client.get_organizations() + response.raise_for_status() + orgs = response.json() + + org = next((o for o in orgs if o["uuid"] == org_handle), None) + if not org: + console.print(f"Organization with handle '{org_handle}' not found.", style="bold red") + return + + settings = Settings() + settings.org_name = org["name"] + settings.org_uuid = org["uuid"] + settings.dump() + + console.print(f"Successfully switched to {org['name']} ({org['uuid']})", style="bold green") + except Exception as e: + console.print(f"Failed to switch organization: {str(e)}", style="bold red") + raise SystemExit(1) + + def current(self): + settings = Settings() + if settings.org_uuid: + console.print(f"Currently logged in to organization {settings.org_name} ({settings.org_uuid})", style="bold green") + else: + console.print("You're not currently logged in to any organization.", style="yellow") + console.print("Use 'crewai org list' to see available organizations.", style="yellow") + console.print("Use 'crewai org switch ' to switch to an organization.", style="yellow") diff --git a/src/crewai/cli/plus_api.py b/src/crewai/cli/plus_api.py index 6961f886e..ca429c04d 100644 --- a/src/crewai/cli/plus_api.py +++ b/src/crewai/cli/plus_api.py @@ -13,6 +13,7 @@ class PlusAPI: """ TOOLS_RESOURCE = "/crewai_plus/api/v1/tools" + ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations" CREWS_RESOURCE = "/crewai_plus/api/v1/crews" AGENTS_RESOURCE = "/crewai_plus/api/v1/agents" @@ -103,3 +104,7 @@ class PlusAPI: def create_crew(self, payload) -> requests.Response: return self._make_request("POST", self.CREWS_RESOURCE, json=payload) + + def get_organizations(self) -> requests.Response: + return self._make_request("GET", self.ORGANIZATIONS_RESOURCE) + \ No newline at end of file diff --git a/tests/cli/organization/__init__.py b/tests/cli/organization/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/cli/organization/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/cli/organization/test_main.py b/tests/cli/organization/test_main.py new file mode 100644 index 000000000..ac82f9f8c --- /dev/null +++ b/tests/cli/organization/test_main.py @@ -0,0 +1,205 @@ +import unittest +from unittest.mock import MagicMock, patch, call + +import pytest +from click.testing import CliRunner +import requests + +from crewai.cli.organization.main import OrganizationCommand +from crewai.cli.cli import list, switch, current + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def org_command(): + with patch.object(OrganizationCommand, '__init__', return_value=None): + command = OrganizationCommand() + yield command + + +@pytest.fixture +def mock_settings(): + with patch('crewai.cli.organization.main.Settings') as mock_settings_class: + mock_settings_instance = MagicMock() + mock_settings_class.return_value = mock_settings_instance + yield mock_settings_instance + + +@patch('crewai.cli.cli.OrganizationCommand') +def test_org_list_command(mock_org_command_class, runner): + mock_org_instance = MagicMock() + mock_org_command_class.return_value = mock_org_instance + + result = runner.invoke(list) + + assert result.exit_code == 0 + mock_org_command_class.assert_called_once() + mock_org_instance.list.assert_called_once() + + +@patch('crewai.cli.cli.OrganizationCommand') +def test_org_switch_command(mock_org_command_class, runner): + mock_org_instance = MagicMock() + mock_org_command_class.return_value = mock_org_instance + + result = runner.invoke(switch, ['test-handle']) + + assert result.exit_code == 0 + mock_org_command_class.assert_called_once() + mock_org_instance.switch.assert_called_once_with('test-handle') + + +@patch('crewai.cli.cli.OrganizationCommand') +def test_org_current_command(mock_org_command_class, runner): + mock_org_instance = MagicMock() + mock_org_command_class.return_value = mock_org_instance + + result = runner.invoke(current) + + assert result.exit_code == 0 + mock_org_command_class.assert_called_once() + mock_org_instance.current.assert_called_once() + + +class TestOrganizationCommand(unittest.TestCase): + def setUp(self): + with patch.object(OrganizationCommand, '__init__', return_value=None): + self.org_command = OrganizationCommand() + self.org_command.plus_api_client = MagicMock() + + @patch('crewai.cli.organization.main.console') + @patch('crewai.cli.organization.main.Table') + def test_list_organizations_success(self, mock_table, mock_console): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [ + {"name": "Org 1", "uuid": "org-123"}, + {"name": "Org 2", "uuid": "org-456"} + ] + self.org_command.plus_api_client = MagicMock() + self.org_command.plus_api_client.get_organizations.return_value = mock_response + + mock_console.print = MagicMock() + + self.org_command.list() + + self.org_command.plus_api_client.get_organizations.assert_called_once() + mock_table.assert_called_once_with(title="Your Organizations") + mock_table.return_value.add_column.assert_has_calls([ + call("Name", style="cyan"), + call("Handle", style="green") + ]) + mock_table.return_value.add_row.assert_has_calls([ + call("Org 1", "org-123"), + call("Org 2", "org-456") + ]) + + @patch('crewai.cli.organization.main.console') + def test_list_organizations_empty(self, mock_console): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [] + self.org_command.plus_api_client = MagicMock() + self.org_command.plus_api_client.get_organizations.return_value = mock_response + + self.org_command.list() + + self.org_command.plus_api_client.get_organizations.assert_called_once() + mock_console.print.assert_called_once_with( + "You don't belong to any organizations yet.", + style="yellow" + ) + + @patch('crewai.cli.organization.main.console') + def test_list_organizations_api_error(self, mock_console): + self.org_command.plus_api_client = MagicMock() + self.org_command.plus_api_client.get_organizations.side_effect = requests.exceptions.RequestException("API Error") + + with pytest.raises(SystemExit): + self.org_command.list() + + self.org_command.plus_api_client.get_organizations.assert_called_once() + mock_console.print.assert_called_once_with( + "Failed to retrieve organization list: API Error", + style="bold red" + ) + + @patch('crewai.cli.organization.main.console') + @patch('crewai.cli.organization.main.Settings') + def test_switch_organization_success(self, mock_settings_class, mock_console): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [ + {"name": "Org 1", "uuid": "org-123"}, + {"name": "Test Org", "uuid": "test-handle"} + ] + self.org_command.plus_api_client = MagicMock() + self.org_command.plus_api_client.get_organizations.return_value = mock_response + + mock_settings_instance = MagicMock() + mock_settings_class.return_value = mock_settings_instance + + self.org_command.switch("test-handle") + + self.org_command.plus_api_client.get_organizations.assert_called_once() + mock_settings_instance.dump.assert_called_once() + assert mock_settings_instance.org_name == "Test Org" + assert mock_settings_instance.org_uuid == "test-handle" + mock_console.print.assert_called_once_with( + "Successfully switched to Test Org (test-handle)", + style="bold green" + ) + + @patch('crewai.cli.organization.main.console') + def test_switch_organization_not_found(self, mock_console): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [ + {"name": "Org 1", "uuid": "org-123"}, + {"name": "Org 2", "uuid": "org-456"} + ] + self.org_command.plus_api_client = MagicMock() + self.org_command.plus_api_client.get_organizations.return_value = mock_response + + self.org_command.switch("non-existent-handle") + + self.org_command.plus_api_client.get_organizations.assert_called_once() + mock_console.print.assert_called_once_with( + "Organization with handle 'non-existent-handle' not found.", + style="bold red" + ) + + @patch('crewai.cli.organization.main.console') + @patch('crewai.cli.organization.main.Settings') + def test_current_organization_with_org(self, mock_settings_class, mock_console): + mock_settings_instance = MagicMock() + mock_settings_instance.org_name = "Test Org" + mock_settings_instance.org_uuid = "test-handle" + mock_settings_class.return_value = mock_settings_instance + + self.org_command.current() + + self.org_command.plus_api_client.get_organizations.assert_not_called() + mock_console.print.assert_called_once_with( + "Currently logged in to organization Test Org (test-handle)", + style="bold green" + ) + + @patch('crewai.cli.organization.main.console') + @patch('crewai.cli.organization.main.Settings') + def test_current_organization_without_org(self, mock_settings_class, mock_console): + mock_settings_instance = MagicMock() + mock_settings_instance.org_uuid = None + mock_settings_class.return_value = mock_settings_instance + + self.org_command.current() + + assert mock_console.print.call_count == 3 + mock_console.print.assert_any_call( + "You're not currently logged in to any organization.", + style="yellow" + )