From db3c8a49bd42342fbc6f5a569248bb51ff42364d Mon Sep 17 00:00:00 2001 From: Lucas Gomide Date: Mon, 9 Jun 2025 13:21:12 -0300 Subject: [PATCH] feat: improve docs and logging for Multi-Org actions in CLI (#2980) * docs: add organization management in our CLI docs * feat: improve user feedback when user is not authenticated * feat: improve logging about current organization while publishing/install a Tool * feat: improve logging when Agent repository is not found during fetch * fix linter offences * test: fix auth token error --- docs/concepts/cli.mdx | 31 ++++++++++ src/crewai/cli/organization/main.py | 23 ++++++-- src/crewai/cli/tools/main.py | 11 +++- src/crewai/utilities/agent_utils.py | 15 ++++- tests/agent_test.py | 57 ++++++++++++++++++ tests/cli/organization/test_main.py | 90 ++++++++++++++++++++--------- tests/cli/tools/test_main.py | 33 ++++++++++- 7 files changed, 226 insertions(+), 34 deletions(-) diff --git a/docs/concepts/cli.mdx b/docs/concepts/cli.mdx index f66d04b44..2a86c7e6e 100644 --- a/docs/concepts/cli.mdx +++ b/docs/concepts/cli.mdx @@ -200,6 +200,37 @@ Deploy the crew or flow to [CrewAI Enterprise](https://app.crewai.com). ``` - Reads your local project configuration. - Prompts you to confirm the environment variables (like `OPENAI_API_KEY`, `SERPER_API_KEY`) found locally. These will be securely stored with the deployment on the Enterprise platform. Ensure your sensitive keys are correctly configured locally (e.g., in a `.env` file) before running this. + +### 11. Organization Management + +Manage your CrewAI Enterprise organizations. + +```shell Terminal +crewai org [COMMAND] [OPTIONS] +``` + +#### Commands: + +- `list`: List all organizations you belong to +```shell Terminal +crewai org list +``` + +- `current`: Display your currently active organization +```shell Terminal +crewai org current +``` + +- `switch`: Switch to a specific organization +```shell Terminal +crewai org switch +``` + + +You must be authenticated to CrewAI Enterprise to use these organization management commands. + + +- **Create a deployment** (continued): - Links the deployment to the corresponding remote GitHub repository (it usually detects this automatically). - **Deploy the Crew**: Once you are authenticated, you can deploy your crew or flow to CrewAI Enterprise. diff --git a/src/crewai/cli/organization/main.py b/src/crewai/cli/organization/main.py index 233f85714..8bf23d531 100644 --- a/src/crewai/cli/organization/main.py +++ b/src/crewai/cli/organization/main.py @@ -1,6 +1,7 @@ from rich.console import Console from rich.table import Table +from requests import HTTPError from crewai.cli.command import BaseCommand, PlusAPIMixin from crewai.cli.config import Settings @@ -16,7 +17,7 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin): 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 @@ -26,8 +27,14 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin): table.add_column("ID", style="green") for org in orgs: table.add_row(org["name"], org["uuid"]) - + console.print(table) + except HTTPError as e: + if e.response.status_code == 401: + console.print("You are not logged in to any organization. Use 'crewai login' to login.", style="bold red") + return + console.print(f"Failed to retrieve organization list: {str(e)}", style="bold red") + raise SystemExit(1) except Exception as e: console.print(f"Failed to retrieve organization list: {str(e)}", style="bold red") raise SystemExit(1) @@ -37,18 +44,24 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin): 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_id), None) if not org: console.print(f"Organization with id '{org_id}' 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 HTTPError as e: + if e.response.status_code == 401: + console.print("You are not logged in to any organization. Use 'crewai login' to login.", style="bold red") + return + console.print(f"Failed to retrieve organization list: {str(e)}", style="bold red") + raise SystemExit(1) except Exception as e: console.print(f"Failed to switch organization: {str(e)}", style="bold red") raise SystemExit(1) diff --git a/src/crewai/cli/tools/main.py b/src/crewai/cli/tools/main.py index 92feec3a0..ecdf972d2 100644 --- a/src/crewai/cli/tools/main.py +++ b/src/crewai/cli/tools/main.py @@ -91,6 +91,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): console.print( f"[green]Found these tools to publish: {', '.join([e['name'] for e in available_exports])}[/green]" ) + self._print_current_organization() with tempfile.TemporaryDirectory() as temp_build_dir: subprocess.run( @@ -136,6 +137,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): ) def install(self, handle: str): + self._print_current_organization() get_response = self.plus_api_client.get_tool(handle) if get_response.status_code == 404: @@ -182,7 +184,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): settings.dump() console.print( - "Successfully authenticated to the tool repository.", style="bold green" + f"Successfully authenticated to the tool repository as {settings.org_name} ({settings.org_uuid}).", style="bold green" ) def _add_package(self, tool_details: dict[str, Any]): @@ -240,3 +242,10 @@ class ToolCommand(BaseCommand, PlusAPIMixin): ) return env + + def _print_current_organization(self): + settings = Settings() + if settings.org_uuid: + console.print(f"Current organization: {settings.org_name} ({settings.org_uuid})", style="bold blue") + else: + console.print("No organization currently set. We recommend setting one before using: `crewai org switch ` command.", style="yellow") diff --git a/src/crewai/utilities/agent_utils.py b/src/crewai/utilities/agent_utils.py index 94a0dc1bf..be0abd92c 100644 --- a/src/crewai/utilities/agent_utils.py +++ b/src/crewai/utilities/agent_utils.py @@ -20,7 +20,10 @@ from crewai.utilities.errors import AgentRepositoryError from crewai.utilities.exceptions.context_window_exceeding_exception import ( LLMContextLengthExceededException, ) +from rich.console import Console +from crewai.cli.config import Settings +console = Console() def parse_tools(tools: List[BaseTool]) -> List[CrewStructuredTool]: """Parse tools to be used for the task.""" @@ -435,6 +438,13 @@ def show_agent_logs( ) +def _print_current_organization(): + settings = Settings() + if settings.org_uuid: + console.print(f"Fetching agent from organization: {settings.org_name} ({settings.org_uuid})", style="bold blue") + else: + console.print("No organization currently set. We recommend setting one before using: `crewai org switch ` command.", style="yellow") + def load_agent_from_repository(from_repository: str) -> Dict[str, Any]: attributes: Dict[str, Any] = {} if from_repository: @@ -444,15 +454,18 @@ def load_agent_from_repository(from_repository: str) -> Dict[str, Any]: from crewai.cli.plus_api import PlusAPI client = PlusAPI(api_key=get_auth_token()) + _print_current_organization() response = client.get_agent(from_repository) if response.status_code == 404: raise AgentRepositoryError( - f"Agent {from_repository} does not exist, make sure the name is correct or the agent is available on your organization" + f"Agent {from_repository} does not exist, make sure the name is correct or the agent is available on your organization." + f"\nIf you are using the wrong organization, switch to the correct one using `crewai org switch ` command.", ) if response.status_code != 200: raise AgentRepositoryError( f"Agent {from_repository} could not be loaded: {response.text}" + f"\nIf you are using the wrong organization, switch to the correct one using `crewai org switch ` command.", ) agent = response.json() diff --git a/tests/agent_test.py b/tests/agent_test.py index ecbbbd4ee..34eef0316 100644 --- a/tests/agent_test.py +++ b/tests/agent_test.py @@ -2126,3 +2126,60 @@ def test_agent_from_repository_agent_not_found(mock_get_agent, mock_get_auth_tok match="Agent test_agent does not exist, make sure the name is correct or the agent is available on your organization", ): Agent(from_repository="test_agent") + + +@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.utilities.agent_utils.Settings") +@patch("crewai.utilities.agent_utils.console") +def test_agent_from_repository_displays_org_info(mock_console, mock_settings, mock_get_agent, mock_get_auth_token): + mock_settings_instance = MagicMock() + mock_settings_instance.org_uuid = "test-org-uuid" + mock_settings_instance.org_name = "Test Organization" + mock_settings.return_value = mock_settings_instance + + mock_get_response = MagicMock() + mock_get_response.status_code = 200 + mock_get_response.json.return_value = { + "role": "test role", + "goal": "test goal", + "backstory": "test backstory", + "tools": [] + } + mock_get_agent.return_value = mock_get_response + + agent = Agent(from_repository="test_agent") + + mock_console.print.assert_any_call( + "Fetching agent from organization: Test Organization (test-org-uuid)", + style="bold blue" + ) + + assert agent.role == "test role" + assert agent.goal == "test goal" + assert agent.backstory == "test backstory" + + +@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.utilities.agent_utils.Settings") +@patch("crewai.utilities.agent_utils.console") +def test_agent_from_repository_without_org_set(mock_console, mock_settings, mock_get_agent, mock_get_auth_token): + mock_settings_instance = MagicMock() + mock_settings_instance.org_uuid = None + mock_settings_instance.org_name = None + mock_settings.return_value = mock_settings_instance + + mock_get_response = MagicMock() + mock_get_response.status_code = 401 + mock_get_response.text = "Unauthorized access" + mock_get_agent.return_value = mock_get_response + + with pytest.raises( + AgentRepositoryError, + match="Agent test_agent could not be loaded: Unauthorized access" + ): + Agent(from_repository="test_agent") + + mock_console.print.assert_any_call( + "No organization currently set. We recommend setting one before using: `crewai org switch ` command.", + style="yellow" + ) diff --git a/tests/cli/organization/test_main.py b/tests/cli/organization/test_main.py index 5f92284ae..c006b25e6 100644 --- a/tests/cli/organization/test_main.py +++ b/tests/cli/organization/test_main.py @@ -33,9 +33,9 @@ def mock_settings(): 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() @@ -45,9 +45,9 @@ def test_org_list_command(mock_org_command_class, runner): 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-id']) - + assert result.exit_code == 0 mock_org_command_class.assert_called_once() mock_org_instance.switch.assert_called_once_with('test-id') @@ -57,9 +57,9 @@ def test_org_switch_command(mock_org_command_class, runner): 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() @@ -70,7 +70,7 @@ class TestOrganizationCommand(unittest.TestCase): 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): @@ -82,11 +82,11 @@ class TestOrganizationCommand(unittest.TestCase): ] 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([ @@ -105,12 +105,12 @@ class TestOrganizationCommand(unittest.TestCase): 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.", + "You don't belong to any organizations yet.", style="yellow" ) @@ -118,14 +118,14 @@ class TestOrganizationCommand(unittest.TestCase): 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", + "Failed to retrieve organization list: API Error", style="bold red" ) @@ -140,12 +140,12 @@ class TestOrganizationCommand(unittest.TestCase): ] 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-id") - + 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" @@ -165,9 +165,9 @@ class TestOrganizationCommand(unittest.TestCase): ] 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-id") - + self.org_command.plus_api_client.get_organizations.assert_called_once() mock_console.print.assert_called_once_with( "Organization with id 'non-existent-id' not found.", @@ -181,9 +181,9 @@ class TestOrganizationCommand(unittest.TestCase): mock_settings_instance.org_name = "Test Org" mock_settings_instance.org_uuid = "test-id" 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-id)", @@ -196,11 +196,49 @@ class TestOrganizationCommand(unittest.TestCase): 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.", + "You're not currently logged in to any organization.", style="yellow" ) + + @patch('crewai.cli.organization.main.console') + def test_list_organizations_unauthorized(self, mock_console): + mock_response = MagicMock() + mock_http_error = requests.exceptions.HTTPError( + "401 Client Error: Unauthorized", + response=MagicMock(status_code=401) + ) + + mock_response.raise_for_status.side_effect = mock_http_error + 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 are not logged in to any organization. Use 'crewai login' to login.", + style="bold red" + ) + + @patch('crewai.cli.organization.main.console') + def test_switch_organization_unauthorized(self, mock_console): + mock_response = MagicMock() + mock_http_error = requests.exceptions.HTTPError( + "401 Client Error: Unauthorized", + response=MagicMock(status_code=401) + ) + + mock_response.raise_for_status.side_effect = mock_http_error + self.org_command.plus_api_client.get_organizations.return_value = mock_response + + self.org_command.switch("test-id") + + self.org_command.plus_api_client.get_organizations.assert_called_once() + mock_console.print.assert_called_once_with( + "You are not logged in to any organization. Use 'crewai login' to login.", + style="bold red" + ) diff --git a/tests/cli/tools/test_main.py b/tests/cli/tools/test_main.py index 005663b56..182c3eb4f 100644 --- a/tests/cli/tools/test_main.py +++ b/tests/cli/tools/test_main.py @@ -56,7 +56,8 @@ def test_create_success(mock_subprocess, capsys, tool_command): @patch("crewai.cli.tools.main.subprocess.run") @patch("crewai.cli.plus_api.PlusAPI.get_tool") -def test_install_success(mock_get, mock_subprocess_run, capsys, tool_command): +@patch("crewai.cli.tools.main.ToolCommand._print_current_organization") +def test_install_success(mock_print_org, mock_get, mock_subprocess_run, capsys, tool_command): mock_get_response = MagicMock() mock_get_response.status_code = 200 mock_get_response.json.return_value = { @@ -85,6 +86,9 @@ def test_install_success(mock_get, mock_subprocess_run, capsys, tool_command): env=unittest.mock.ANY, ) + # Verify _print_current_organization was called + mock_print_org.assert_called_once() + @patch("crewai.cli.tools.main.subprocess.run") @patch("crewai.cli.plus_api.PlusAPI.get_tool") def test_install_success_from_pypi(mock_get, mock_subprocess_run, capsys, tool_command): @@ -166,7 +170,9 @@ def test_publish_when_not_in_sync(mock_is_synced, capsys, tool_command): @patch("crewai.cli.plus_api.PlusAPI.publish_tool") @patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False) @patch("crewai.cli.tools.main.extract_available_exports", return_value=[{"name": "SampleTool"}]) +@patch("crewai.cli.tools.main.ToolCommand._print_current_organization") def test_publish_when_not_in_sync_and_force( + mock_print_org, mock_available_exports, mock_is_synced, mock_publish, @@ -202,6 +208,7 @@ def test_publish_when_not_in_sync_and_force( encoded_file=unittest.mock.ANY, available_exports=[{"name": "SampleTool"}], ) + mock_print_org.assert_called_once() @patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") @@ -329,3 +336,27 @@ def test_publish_api_error( assert "Request to Enterprise API failed" in output mock_publish.assert_called_once() + + + +@patch("crewai.cli.tools.main.Settings") +def test_print_current_organization_with_org(mock_settings, capsys, tool_command): + mock_settings_instance = MagicMock() + mock_settings_instance.org_uuid = "test-org-uuid" + mock_settings_instance.org_name = "Test Organization" + mock_settings.return_value = mock_settings_instance + tool_command._print_current_organization() + output = capsys.readouterr().out + assert "Current organization: Test Organization (test-org-uuid)" in output + + +@patch("crewai.cli.tools.main.Settings") +def test_print_current_organization_without_org(mock_settings, capsys, tool_command): + mock_settings_instance = MagicMock() + mock_settings_instance.org_uuid = None + mock_settings_instance.org_name = None + mock_settings.return_value = mock_settings_instance + tool_command._print_current_organization() + output = capsys.readouterr().out + assert "No organization currently set" in output + assert "org switch " in output