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
This commit is contained in:
Lucas Gomide
2025-06-09 13:21:12 -03:00
committed by GitHub
parent 8a37b535ed
commit db3c8a49bd
7 changed files with 226 additions and 34 deletions

View File

@@ -200,6 +200,37 @@ Deploy the crew or flow to [CrewAI Enterprise](https://app.crewai.com).
``` ```
- Reads your local project configuration. - 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. - 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 <organization_id>
```
<Note>
You must be authenticated to CrewAI Enterprise to use these organization management commands.
</Note>
- **Create a deployment** (continued):
- Links the deployment to the corresponding remote GitHub repository (it usually detects this automatically). - 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. - **Deploy the Crew**: Once you are authenticated, you can deploy your crew or flow to CrewAI Enterprise.

View File

@@ -1,6 +1,7 @@
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from requests import HTTPError
from crewai.cli.command import BaseCommand, PlusAPIMixin from crewai.cli.command import BaseCommand, PlusAPIMixin
from crewai.cli.config import Settings from crewai.cli.config import Settings
@@ -28,6 +29,12 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin):
table.add_row(org["name"], org["uuid"]) table.add_row(org["name"], org["uuid"])
console.print(table) 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: except Exception as e:
console.print(f"Failed to retrieve organization list: {str(e)}", style="bold red") console.print(f"Failed to retrieve organization list: {str(e)}", style="bold red")
raise SystemExit(1) raise SystemExit(1)
@@ -49,6 +56,12 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin):
settings.dump() settings.dump()
console.print(f"Successfully switched to {org['name']} ({org['uuid']})", style="bold green") 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: except Exception as e:
console.print(f"Failed to switch organization: {str(e)}", style="bold red") console.print(f"Failed to switch organization: {str(e)}", style="bold red")
raise SystemExit(1) raise SystemExit(1)

View File

@@ -91,6 +91,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
console.print( console.print(
f"[green]Found these tools to publish: {', '.join([e['name'] for e in available_exports])}[/green]" 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: with tempfile.TemporaryDirectory() as temp_build_dir:
subprocess.run( subprocess.run(
@@ -136,6 +137,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
) )
def install(self, handle: str): def install(self, handle: str):
self._print_current_organization()
get_response = self.plus_api_client.get_tool(handle) get_response = self.plus_api_client.get_tool(handle)
if get_response.status_code == 404: if get_response.status_code == 404:
@@ -182,7 +184,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
settings.dump() settings.dump()
console.print( 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]): def _add_package(self, tool_details: dict[str, Any]):
@@ -240,3 +242,10 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
) )
return env 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 <org_id>` command.", style="yellow")

View File

@@ -20,7 +20,10 @@ from crewai.utilities.errors import AgentRepositoryError
from crewai.utilities.exceptions.context_window_exceeding_exception import ( from crewai.utilities.exceptions.context_window_exceeding_exception import (
LLMContextLengthExceededException, LLMContextLengthExceededException,
) )
from rich.console import Console
from crewai.cli.config import Settings
console = Console()
def parse_tools(tools: List[BaseTool]) -> List[CrewStructuredTool]: def parse_tools(tools: List[BaseTool]) -> List[CrewStructuredTool]:
"""Parse tools to be used for the task.""" """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 <org_id>` command.", style="yellow")
def load_agent_from_repository(from_repository: str) -> Dict[str, Any]: def load_agent_from_repository(from_repository: str) -> Dict[str, Any]:
attributes: Dict[str, Any] = {} attributes: Dict[str, Any] = {}
if from_repository: 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 from crewai.cli.plus_api import PlusAPI
client = PlusAPI(api_key=get_auth_token()) client = PlusAPI(api_key=get_auth_token())
_print_current_organization()
response = client.get_agent(from_repository) response = client.get_agent(from_repository)
if response.status_code == 404: if response.status_code == 404:
raise AgentRepositoryError( 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 <org_id>` command.",
) )
if response.status_code != 200: if response.status_code != 200:
raise AgentRepositoryError( raise AgentRepositoryError(
f"Agent {from_repository} could not be loaded: {response.text}" 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 <org_id>` command.",
) )
agent = response.json() agent = response.json()

View File

@@ -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", 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") 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 <org_id>` command.",
style="yellow"
)

View File

@@ -204,3 +204,41 @@ class TestOrganizationCommand(unittest.TestCase):
"You're not currently logged in to any organization.", "You're not currently logged in to any organization.",
style="yellow" 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"
)

View File

@@ -56,7 +56,8 @@ def test_create_success(mock_subprocess, capsys, tool_command):
@patch("crewai.cli.tools.main.subprocess.run") @patch("crewai.cli.tools.main.subprocess.run")
@patch("crewai.cli.plus_api.PlusAPI.get_tool") @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 = MagicMock()
mock_get_response.status_code = 200 mock_get_response.status_code = 200
mock_get_response.json.return_value = { 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, 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.tools.main.subprocess.run")
@patch("crewai.cli.plus_api.PlusAPI.get_tool") @patch("crewai.cli.plus_api.PlusAPI.get_tool")
def test_install_success_from_pypi(mock_get, mock_subprocess_run, capsys, tool_command): 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.plus_api.PlusAPI.publish_tool")
@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False) @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.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( def test_publish_when_not_in_sync_and_force(
mock_print_org,
mock_available_exports, mock_available_exports,
mock_is_synced, mock_is_synced,
mock_publish, mock_publish,
@@ -202,6 +208,7 @@ def test_publish_when_not_in_sync_and_force(
encoded_file=unittest.mock.ANY, encoded_file=unittest.mock.ANY,
available_exports=[{"name": "SampleTool"}], available_exports=[{"name": "SampleTool"}],
) )
mock_print_org.assert_called_once()
@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") @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 assert "Request to Enterprise API failed" in output
mock_publish.assert_called_once() 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 <org_id>" in output