diff --git a/lib/cli/src/crewai_cli/authentication/main.py b/lib/cli/src/crewai_cli/authentication/main.py index a57b96c9d..a4b2f221c 100644 --- a/lib/cli/src/crewai_cli/authentication/main.py +++ b/lib/cli/src/crewai_cli/authentication/main.py @@ -12,6 +12,7 @@ from crewai_core.auth.oauth2 import ( ProviderFactory as ProviderFactory, console, ) +from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL from crewai_core.settings import Settings from crewai_cli.authentication.token import get_auth_token @@ -32,7 +33,11 @@ class AuthenticationCommand(_BaseAuthenticationCommand): def _select_organization(self) -> None: """Let the user choose the organization for this CLI login.""" try: - response = PlusAPI(api_key=get_auth_token()).get_organizations() + settings = Settings() + response = PlusAPI( + api_key=get_auth_token(), + base_url=settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL, + ).get_organizations() response.raise_for_status() organizations = response.json() except Exception as error: @@ -84,7 +89,10 @@ class AuthenticationCommand(_BaseAuthenticationCommand): end="", ) - ToolCommand().login() + settings = Settings() + ToolCommand( + base_url=settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL + ).login() console.print( "Success!\n", diff --git a/lib/cli/src/crewai_cli/command.py b/lib/cli/src/crewai_cli/command.py index d5e62cf55..b9c681cf0 100644 --- a/lib/cli/src/crewai_cli/command.py +++ b/lib/cli/src/crewai_cli/command.py @@ -24,10 +24,10 @@ class BaseCommand: class PlusAPIMixin: - def __init__(self, telemetry: Telemetry) -> None: + def __init__(self, telemetry: Telemetry, *, base_url: str | None = None) -> None: try: telemetry.set_tracer() - self.plus_api_client = PlusAPI(api_key=get_auth_token()) + self.plus_api_client = PlusAPI(api_key=get_auth_token(), base_url=base_url) except Exception: telemetry.deploy_signup_error_span() console.print( diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py index 7640b81fa..5397f21e6 100644 --- a/lib/cli/src/crewai_cli/create_json_crew.py +++ b/lib/cli/src/crewai_cli/create_json_crew.py @@ -12,6 +12,7 @@ from typing import Any import warnings import click +from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL from crewai_core.platform_app_defaults import PLATFORM_APP_DEFAULT_TOOLS from crewai_core.platform_apps import ( PLATFORM_APPLICATION_CATALOG, @@ -1043,12 +1044,16 @@ def _platform_app_name(app: str) -> str: def _get_logged_in_platform_token() -> str: """Fetch the selected organization's integration token from the saved login.""" login_token = get_auth_token() - organization_id = Settings().org_uuid + settings = Settings() + organization_id = settings.org_uuid if not organization_id: raise _NoOrganizationSelectedError("No CrewAI organization is selected") - return PlusAPI( - api_key=login_token, organization_id=organization_id - ).get_integration_token() + api = PlusAPI( + api_key=login_token, + organization_id=organization_id, + base_url=settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL, + ) + return api.get_integration_token() def _get_platform_token_from_login() -> str: @@ -1084,24 +1089,35 @@ def _get_platform_token_from_login() -> str: def _check_platform_app( - app: str, application_selector: Any, client_for_selector: Any + app: str, + application_selector: Any, + client_for_selector: Any, + base_url: str | None = None, ) -> tuple[Any | None, Exception | None]: """Check one AMP application with the synchronous platform client.""" try: selector = application_selector.from_string(app) - return client_for_selector(selector).get_actions([selector]), None + client = client_for_selector(selector, base_url=base_url) + return client.get_actions([selector]), None except Exception as error: return None, error async def _check_platform_apps_concurrently( - apps: list[str], application_selector: Any, client_for_selector: Any + apps: list[str], + application_selector: Any, + client_for_selector: Any, + base_url: str | None = None, ) -> list[tuple[Any | None, Exception | None]]: """Run independent AMP application checks concurrently.""" return await asyncio.gather( *( asyncio.to_thread( - _check_platform_app, app, application_selector, client_for_selector + _check_platform_app, + app, + application_selector, + client_for_selector, + base_url, ) for app in apps ) @@ -1146,7 +1162,10 @@ def _report_platform_app_validation( def _validate_platform_apps( - apps: list[str], application_selector: Any, client_for_selector: Any + apps: list[str], + application_selector: Any, + client_for_selector: Any, + base_url: str | None = None, ) -> tuple[list[str], bool]: """Check selected AMP applications concurrently when no event loop is running.""" failed: list[str] = [] @@ -1163,7 +1182,7 @@ def _validate_platform_apps( ) results = asyncio.run( _check_platform_apps_concurrently( - apps, application_selector, client_for_selector + apps, application_selector, client_for_selector, base_url ) ) for app, (actions, error) in zip(apps, results, strict=True): @@ -1179,7 +1198,7 @@ def _validate_platform_apps( fg="cyan", ) actions, error = _check_platform_app( - app, application_selector, client_for_selector + app, application_selector, client_for_selector, base_url ) if _report_platform_app_validation(app, actions, error, failed): return failed, True @@ -1187,42 +1206,33 @@ def _validate_platform_apps( def _show_platform_validation_guidance( - failed_apps: list[str], token_invalid: bool + failed_apps: list[str], token_invalid: bool, base_url: str ) -> None: """Tell the user what to fix before revalidating AMP integrations.""" click.echo() if token_invalid: click.secho( - " Check your Enterprise Action Auth Token in AMP.", + " Your CrewAI login could not validate the Enterprise Action Auth Token.", fg="yellow", ) return failed_app_names = [_platform_app_name(app) for app in failed_apps] click.secho( - " Check the " + " Connect the " f"{', '.join(failed_app_names)} integration" - f"{'s' if len(failed_app_names) != 1 else ''} and your CrewAI " - "Enterprise Action Auth Token in AMP.", + f"{'s' if len(failed_app_names) != 1 else ''} in CrewAI AMP:", fg="yellow", ) + click.secho(f" {base_url.rstrip('/')}", fg="cyan") -def _prompt_platform_revalidation_token() -> str: - """Prompt for an optional replacement token before the next validation pass.""" +def _confirm_platform_revalidation() -> bool: + """Ask whether to retry integration validation after AMP is updated.""" click.echo() - return str( - click.prompt( - click.style( - " Press Enter to revalidate, or enter a replacement token", - fg="cyan", - ), - default="", - show_default=False, - hide_input=False, - prompt_suffix=click.style(" > ", fg="bright_white"), - ) - ).strip() + return click.confirm( + click.style(" Revalidate integrations?", fg="cyan"), default=True + ) def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None: @@ -1265,8 +1275,13 @@ def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None: f"Enterprise Action Auth Token found via {credential_location}", dim=True ) os.environ["CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN"] = token + settings = Settings() + base_url = settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL failed_apps, token_invalid = _validate_platform_apps( - apps, ApplicationSelector, client_for_selector + apps, + ApplicationSelector, + client_for_selector, + base_url, ) if not failed_apps and not token_invalid: _success("Enterprise Action Auth Token set", bold=True) @@ -1276,12 +1291,9 @@ def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None: ) return token - _show_platform_validation_guidance(failed_apps, token_invalid) - replacement_token = _prompt_platform_revalidation_token() - if replacement_token: - token = replacement_token - credential_location = "interactive replacement prompt" - os.environ["CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN"] = token + _show_platform_validation_guidance(failed_apps, token_invalid, base_url) + if not _confirm_platform_revalidation(): + raise click.Abort() # ── Main ──────────────────────────────────────────────────────── diff --git a/lib/cli/src/crewai_cli/tools/main.py b/lib/cli/src/crewai_cli/tools/main.py index fcf693eb3..0e3d900c9 100644 --- a/lib/cli/src/crewai_cli/tools/main.py +++ b/lib/cli/src/crewai_cli/tools/main.py @@ -61,9 +61,9 @@ class ToolCommand(BaseCommand, PlusAPIMixin): A class to handle tool repository related operations for CrewAI projects. """ - def __init__(self) -> None: + def __init__(self, *, base_url: str | None = None) -> None: BaseCommand.__init__(self) - PlusAPIMixin.__init__(self, telemetry=self._telemetry) + PlusAPIMixin.__init__(self, telemetry=self._telemetry, base_url=base_url) def create(self, handle: str) -> None: self._ensure_not_in_project() diff --git a/lib/cli/tests/authentication/test_auth_main.py b/lib/cli/tests/authentication/test_auth_main.py index 664715be6..9a910f5dd 100644 --- a/lib/cli/tests/authentication/test_auth_main.py +++ b/lib/cli/tests/authentication/test_auth_main.py @@ -174,11 +174,12 @@ class TestAuthenticationCommand: mock_settings_instance = MagicMock() mock_settings_instance.org_name = "Test Org" mock_settings_instance.org_uuid = "test-uuid-123" + mock_settings_instance.enterprise_base_url = "https://app.crewai.com" mock_settings.return_value = mock_settings_instance self.auth_command._login_to_tool_repository() - mock_tool_command.assert_called_once() + mock_tool_command.assert_called_once_with(base_url="https://app.crewai.com") mock_tool_instance.login.assert_called_once() expected_calls = [ @@ -332,10 +333,13 @@ class TestAuthenticationCommand: {"name": "Research", "uuid": "org-research"}, ] mock_plus_api.return_value.get_organizations.return_value = response + mock_settings.return_value.enterprise_base_url = "https://app.crewai.com" self.auth_command._select_organization() - mock_plus_api.assert_called_once_with(api_key="token") + mock_plus_api.assert_called_once_with( + api_key="token", base_url="https://app.crewai.com" + ) response.raise_for_status.assert_called_once() mock_pick.assert_called_once_with( "Select an organization (Name / ID):", @@ -360,7 +364,7 @@ class TestAuthenticationCommand: self.auth_command._select_organization() - mock_settings.assert_not_called() + mock_settings.return_value.dump.assert_not_called() @patch("crewai_cli.authentication.main.pick", return_value="org-unknown") @patch("crewai_cli.authentication.main.console") diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py index ef5f81339..6065200b6 100644 --- a/lib/cli/tests/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -785,17 +785,27 @@ def test_platform_validation_deduplicates_actions_by_application() -> None: assert apps == ["github", "gmail"] -def test_platform_revalidation_prompt_shows_entered_value(monkeypatch) -> None: - prompt_options: list[dict[str, object]] = [] +def test_platform_revalidation_requires_confirmation(monkeypatch) -> None: + confirm_options: list[dict[str, object]] = [] - def prompt(_label: str, **kwargs: object) -> str: - prompt_options.append(kwargs) - return "token" + def confirm(_label: str, **kwargs: object) -> bool: + confirm_options.append(kwargs) + return True - monkeypatch.setattr(json_crew.click, "prompt", prompt) + monkeypatch.setattr(json_crew.click, "confirm", confirm) - assert json_crew._prompt_platform_revalidation_token() == "token" - assert [options["hide_input"] for options in prompt_options] == [False] + assert json_crew._confirm_platform_revalidation() + assert confirm_options == [{"default": True}] + + +def test_platform_validation_guidance_links_to_amp(capsys) -> None: + json_crew._show_platform_validation_guidance( + ["whatsapp"], False, "https://app.crewai.com/" + ) + + output = capsys.readouterr().out + assert "Connect the WhatsApp integration in CrewAI AMP" in output + assert "https://app.crewai.com" in output def test_get_logged_in_platform_token_uses_saved_login_and_organization( @@ -804,8 +814,14 @@ def test_get_logged_in_platform_token_uses_saved_login_and_organization( client = mock.Mock() client.get_integration_token.return_value = "integration-token" monkeypatch.setattr(json_crew, "get_auth_token", lambda: "login-token") + monkeypatch.setenv("CREWAI_PLUS_URL", "https://fake.crewai.com") monkeypatch.setattr( - json_crew, "Settings", lambda: mock.Mock(org_uuid="organization-id") + json_crew, + "Settings", + lambda: mock.Mock( + org_uuid="organization-id", + enterprise_base_url="https://app.crewai.com", + ), ) plus_api = mock.Mock(return_value=client) monkeypatch.setattr(json_crew, "PlusAPI", plus_api) @@ -813,7 +829,9 @@ def test_get_logged_in_platform_token_uses_saved_login_and_organization( assert json_crew._get_logged_in_platform_token() == "integration-token" plus_api.assert_called_once_with( - api_key="login-token", organization_id="organization-id" + api_key="login-token", + organization_id="organization-id", + base_url="https://app.crewai.com", ) client.get_integration_token.assert_called_once_with() @@ -934,7 +952,7 @@ def test_platform_auth_suppresses_warnings_only_while_importing_tools( fake_client.get_actions.return_value = [object()] fake_integrations_client = mock.Mock( ApplicationSelector=FakeApplicationSelector, - client_for_selector=lambda _selector: fake_client, + client_for_selector=lambda _selector, **_kwargs: fake_client, ) original_import = builtins.__import__ @@ -976,7 +994,7 @@ def test_platform_auth_fetches_token_from_login_when_env_is_missing(monkeypatch) fake_client.get_actions.return_value = [object()] fake_integrations_client = mock.Mock( ApplicationSelector=FakeApplicationSelector, - client_for_selector=lambda _selector: fake_client, + client_for_selector=lambda _selector, **_kwargs: fake_client, ) original_import = builtins.__import__ @@ -1013,7 +1031,7 @@ def test_platform_validation_checks_apps_concurrently(): json_crew._check_platform_apps_concurrently( ["github", "gmail"], FakeApplicationSelector, - lambda _selector: FakeClient(), + lambda _selector, **_kwargs: FakeClient(), ) ) @@ -1033,7 +1051,7 @@ def test_platform_validation_announces_concurrent_apps_together(capsys): failed_apps, token_invalid = json_crew._validate_platform_apps( ["github", "gmail", "google_calendar"], FakeApplicationSelector, - lambda _selector: FakeClient(), + lambda _selector, **_kwargs: FakeClient(), ) output = capsys.readouterr().out @@ -1042,6 +1060,31 @@ def test_platform_validation_announces_concurrent_apps_together(capsys): assert "Checking Enterprise Action Auth Token and GitHub" not in output +def test_platform_validation_passes_configured_amp_url_to_client_factory() -> None: + class FakeApplicationSelector: + @classmethod + def from_string(cls, value: str) -> str: + return value + + received_base_urls: list[str | None] = [] + + class FakeClient: + def get_actions(self, _selectors): + return [object()] + + def client_factory(_selector, *, base_url=None): + received_base_urls.append(base_url) + return FakeClient() + + assert json_crew._validate_platform_apps( + ["github"], + FakeApplicationSelector, + client_factory, + "https://app.crewai.com", + ) == ([], False) + assert received_base_urls == ["https://app.crewai.com"] + + def test_platform_validation_falls_back_to_sequential_checks(monkeypatch, capsys): class FakeApplicationSelector: @classmethod @@ -1063,7 +1106,7 @@ def test_platform_validation_falls_back_to_sequential_checks(monkeypatch, capsys failed_apps, token_invalid = json_crew._validate_platform_apps( ["github", "gmail"], FakeApplicationSelector, - lambda app: FakeClient(app), + lambda app, **_kwargs: FakeClient(app), ) assert (failed_apps, token_invalid) == ([], False) diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/integrations_client.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/integrations_client.py index af6e78adc..a4aac6b8d 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/integrations_client.py +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/integrations_client.py @@ -132,9 +132,12 @@ class ClipperClient: _RESOURCE = "/clipper/v1" + def __init__(self, *, base_url: str | None = None) -> None: + self._base_url = base_url + def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]: """Get the actions available for the selected applications.""" - plus_api = PlusAPI() + plus_api = PlusAPI(base_url=self._base_url) base_url = f"{plus_api.base_url.rstrip('/')}{self._RESOURCE}" headers = self._headers() tool_infos: list[ToolInfo] = [] @@ -177,7 +180,7 @@ class ClipperClient: self, tool: ToolInfo, arguments: dict[str, Any] ) -> ToolExecutionResult: """Execute an action with the given arguments.""" - plus_api = PlusAPI() + plus_api = PlusAPI(base_url=self._base_url) payload: dict[str, Any] = {"arguments": arguments} if tool.connection_id is not None: payload["connection_id"] = str(tool.connection_id) @@ -230,9 +233,12 @@ class ClipperClient: class LegacyClient: """Use the existing CrewAI platform integrations API.""" + def __init__(self, *, base_url: str | None = None) -> None: + self._base_url = base_url + def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]: """Get the actions available for the selected applications.""" - plus_api = PlusAPI() + plus_api = PlusAPI(base_url=self._base_url) apps = [ f"{selector.app}/{selector.action}" if selector.action is not None @@ -279,7 +285,7 @@ class LegacyClient: self, tool: ToolInfo, arguments: dict[str, Any] ) -> ToolExecutionResult: """Execute an action with the given arguments.""" - plus_api = PlusAPI() + plus_api = PlusAPI(base_url=self._base_url) response = requests.post( url=( f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}" @@ -314,8 +320,10 @@ class LegacyClient: return ToolExecutionSuccess(output=data) -def client_for_selector(selector: ApplicationSelector) -> IntegrationsClient: +def client_for_selector( + selector: ApplicationSelector, *, base_url: str | None = None +) -> IntegrationsClient: """Select the integrations client for an application selector.""" if selector.connection_id is not None: - return ClipperClient() - return LegacyClient() + return ClipperClient(base_url=base_url) + return LegacyClient(base_url=base_url) diff --git a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_integrations_client.py b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_integrations_client.py index f1c1f4df0..82f03741f 100644 --- a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_integrations_client.py +++ b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_integrations_client.py @@ -53,7 +53,7 @@ def test_clipper_client_discovers_selected_actions(mock_get: Mock) -> None: mock_get.side_effect = [index_response, show_response] connection_id = UUID("550e8400-e29b-41d4-a716-446655440000") - tools = ClipperClient().get_actions( + tools = ClipperClient(base_url="https://configured.example.test/").get_actions( [ ApplicationSelector.from_string(f"github@{connection_id}"), ApplicationSelector.from_string("github/create_issue"), @@ -83,14 +83,14 @@ def test_clipper_client_discovers_selected_actions(mock_get: Mock) -> None: } assert mock_get.call_args_list == [ call( - "https://platform.example.test/clipper/v1/applications/github/tools", + "https://configured.example.test/clipper/v1/applications/github/tools", headers=headers, params={"connection_id": str(connection_id)}, timeout=30, verify=True, ), call( - "https://platform.example.test/clipper/v1/applications/github/tools/create_issue", + "https://configured.example.test/clipper/v1/applications/github/tools/create_issue", headers=headers, params={}, timeout=30,