From 93e786d2637c464e31ce776c04359f821ed01104 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 6 May 2026 20:46:46 +0800 Subject: [PATCH] refactor: extract CLI into standalone crewai-cli package --- .pre-commit-config.yaml | 2 +- conftest.py | 6 +- lib/cli/README.md | 26 ++ lib/cli/pyproject.toml | 43 ++ lib/cli/src/crewai_cli/__init__.py | 1 + .../src/crewai_cli}/add_crew_to_flow.py | 4 +- .../src/crewai_cli/authentication/__init__.py | 8 + .../crewai_cli/authentication/constants.py | 8 + lib/cli/src/crewai_cli/authentication/main.py | 60 +++ .../authentication/providers/__init__.py | 1 + .../authentication/providers/auth0.py | 8 + .../authentication/providers/base_provider.py | 8 + .../authentication/providers/entra_id.py | 8 + .../authentication/providers/keycloak.py | 8 + .../authentication/providers/okta.py | 8 + .../authentication/providers/workos.py | 8 + .../src/crewai_cli/authentication/token.py | 11 + .../src/crewai_cli/authentication/utils.py | 8 + .../src/crewai_cli}/checkpoint_cli.py | 0 .../src/crewai_cli}/checkpoint_tui.py | 2 +- .../crewai/cli => cli/src/crewai_cli}/cli.py | 141 +++---- .../cli => cli/src/crewai_cli}/command.py | 13 +- lib/cli/src/crewai_cli/config.py | 30 ++ .../cli => cli/src/crewai_cli}/constants.py | 0 .../cli => cli/src/crewai_cli}/create_crew.py | 6 +- .../cli => cli/src/crewai_cli}/create_flow.py | 4 +- lib/cli/src/crewai_cli/crew_chat.py | 23 ++ .../src/crewai_cli/deploy}/__init__.py | 0 .../cli => cli/src/crewai_cli}/deploy/main.py | 8 +- .../src/crewai_cli}/deploy/validate.py | 4 +- .../src/crewai_cli/enterprise}/__init__.py | 0 .../src/crewai_cli}/enterprise/main.py | 8 +- .../src/crewai_cli}/evaluate_crew.py | 4 +- .../crewai/cli => cli/src/crewai_cli}/git.py | 0 .../src/crewai_cli}/install_crew.py | 2 +- .../src/crewai_cli}/kickoff_flow.py | 0 .../cli => cli/src/crewai_cli}/memory_tui.py | 0 .../src/crewai_cli}/organization/__init__.py | 0 .../src/crewai_cli}/organization/main.py | 4 +- .../cli => cli/src/crewai_cli}/plot_flow.py | 0 lib/cli/src/crewai_cli/plus_api.py | 12 + .../cli => cli/src/crewai_cli}/provider.py | 2 +- .../src/crewai_cli/py.typed} | 0 .../crewai_cli}/remote_template/__init__.py | 0 .../src/crewai_cli}/remote_template/main.py | 2 +- .../src/crewai_cli}/replay_from_task.py | 4 +- .../src/crewai_cli/reset_memories_command.py | 31 ++ .../cli => cli/src/crewai_cli}/run_crew.py | 6 +- .../src/crewai_cli}/settings/__init__.py | 0 .../src/crewai_cli}/settings/main.py | 8 +- .../src/crewai_cli}/shared/__init__.py | 0 .../src/crewai_cli/shared/token_manager.py | 12 + lib/cli/src/crewai_cli/task_outputs.py | 67 ++++ .../src/crewai_cli}/templates/AGENTS.md | 0 .../src/crewai_cli}/templates/__init__.py | 0 .../src/crewai_cli}/templates/crew/.gitignore | 0 .../src/crewai_cli}/templates/crew/README.md | 0 .../crewai_cli}/templates/crew/__init__.py | 0 .../templates/crew/config/agents.yaml | 0 .../templates/crew/config/tasks.yaml | 0 .../src/crewai_cli}/templates/crew/crew.py | 0 .../crew/knowledge/user_preference.txt | 0 .../src/crewai_cli}/templates/crew/main.py | 0 .../crewai_cli}/templates/crew/pyproject.toml | 0 .../templates/crew/tools/__init__.py | 0 .../templates/crew/tools/custom_tool.py | 0 .../src/crewai_cli}/templates/flow/.gitignore | 0 .../src/crewai_cli}/templates/flow/README.md | 0 .../crewai_cli}/templates/flow/__init__.py | 0 .../crews/content_crew/config/agents.yaml | 0 .../flow/crews/content_crew/config/tasks.yaml | 0 .../flow/crews/content_crew/content_crew.py | 0 .../src/crewai_cli}/templates/flow/main.py | 0 .../crewai_cli}/templates/flow/pyproject.toml | 0 .../templates/flow/tools/__init__.py | 0 .../templates/flow/tools/custom_tool.py | 0 .../src/crewai_cli}/templates/tool/.gitignore | 0 .../src/crewai_cli}/templates/tool/README.md | 0 .../crewai_cli}/templates/tool/pyproject.toml | 0 .../tool/src/{{folder_name}}/__init__.py | 0 .../tool/src/{{folder_name}}/tool.py | 0 .../src/crewai_cli}/tools/__init__.py | 0 .../cli => cli/src/crewai_cli}/tools/main.py | 45 ++- .../cli => cli/src/crewai_cli}/train_crew.py | 0 .../src/crewai_cli}/triggers/__init__.py | 2 +- .../src/crewai_cli}/triggers/main.py | 2 +- .../cli => cli/src/crewai_cli}/update_crew.py | 2 +- lib/cli/src/crewai_cli/user_data.py | 22 ++ lib/cli/src/crewai_cli/utils.py | 137 +++++++ lib/cli/src/crewai_cli/version.py | 24 ++ .../cli/enterprise => cli/tests}/__init__.py | 0 .../tests/authentication}/__init__.py | 0 .../authentication/providers/__init__.py | 0 .../authentication/providers/test_auth0.py | 91 +++++ .../authentication/providers/test_entra_id.py | 141 +++++++ .../authentication/providers/test_keycloak.py | 138 +++++++ .../authentication/providers/test_okta.py | 257 ++++++++++++ .../authentication/providers/test_workos.py | 100 +++++ .../tests}/authentication/test_auth_main.py | 58 +-- lib/cli/tests/authentication/test_utils.py | 107 +++++ .../cli => cli/tests}/deploy/__init__.py | 0 .../tests}/deploy/test_deploy_main.py | 33 +- .../cli => cli/tests}/deploy/test_validate.py | 12 +- lib/cli/tests/enterprise/__init__.py | 0 .../cli => cli/tests}/enterprise/test_main.py | 26 +- .../tests}/organization/__init__.py | 0 .../tests}/organization/test_main.py | 38 +- lib/cli/tests/test_cli.py | 255 ++++++++++++ lib/cli/tests/test_config.py | 148 +++++++ lib/cli/tests/test_constants.py | 20 + .../cli => cli/tests}/test_create_crew.py | 44 +-- .../tests/cli => cli/tests}/test_crew_test.py | 20 +- .../tests/cli => cli/tests}/test_git.py | 2 +- lib/cli/tests/test_plus_api.py | 359 +++++++++++++++++ .../tests}/test_settings_command.py | 8 +- lib/cli/tests/test_token_manager.py | 293 ++++++++++++++ .../cli => cli/tests}/test_train_crew.py | 16 +- lib/cli/tests/test_utils.py | 107 +++++ lib/cli/tests/test_version.py | 374 ++++++++++++++++++ lib/cli/tests/tools/__init__.py | 0 .../cli => cli/tests}/tools/test_main.py | 132 ++++--- .../cli => cli/tests}/triggers/test_main.py | 22 +- lib/crewai-core/README.md | 8 + lib/crewai-core/pyproject.toml | 38 ++ lib/crewai-core/src/crewai_core/__init__.py | 1 + .../src/crewai_core/auth/__init__.py | 24 ++ .../src/crewai_core/auth/constants.py | 8 + .../src/crewai_core/auth/oauth2.py} | 79 ++-- .../crewai_core/auth/providers/__init__.py | 1 + .../src/crewai_core/auth}/providers/auth0.py | 8 +- .../auth/providers/base_provider.py | 46 +++ .../crewai_core/auth}/providers/entra_id.py | 8 +- .../crewai_core/auth}/providers/keycloak.py | 8 +- .../src/crewai_core/auth}/providers/okta.py | 8 +- .../src/crewai_core/auth}/providers/workos.py | 8 +- lib/crewai-core/src/crewai_core/auth/token.py | 17 + .../src/crewai_core/auth}/utils.py | 32 +- lib/crewai-core/src/crewai_core/constants.py | 22 ++ lib/crewai-core/src/crewai_core/lock_store.py | 89 +++++ lib/crewai-core/src/crewai_core/paths.py | 26 ++ .../src/crewai_core}/plus_api.py | 14 +- lib/crewai-core/src/crewai_core/printer.py | 103 +++++ lib/crewai-core/src/crewai_core/project.py | 109 +++++ lib/crewai-core/src/crewai_core/py.typed | 0 .../src/crewai_core/settings.py} | 54 ++- lib/crewai-core/src/crewai_core/telemetry.py | 272 +++++++++++++ .../src/crewai_core}/token_manager.py | 63 +-- .../src/crewai_core/tool_credentials.py | 56 +++ lib/crewai-core/src/crewai_core/user_data.py | 91 +++++ .../src/crewai_core}/version.py | 85 ++-- lib/crewai-core/tests/__init__.py | 0 lib/crewai-core/tests/test_smoke.py | 96 +++++ .../tools/couchbase_tool/couchbase_tool.py | 2 +- .../tools/daytona_sandbox_tool/__init__.py | 1 + lib/crewai/pyproject.toml | 7 +- lib/crewai/src/crewai/a2a/auth/utils.py | 2 +- .../src/crewai/agents/crew_agent_executor.py | 2 +- lib/crewai/src/crewai/agents/step_executor.py | 2 +- lib/crewai/src/crewai/auth/__init__.py | 22 ++ lib/crewai/src/crewai/auth/constants.py | 8 + lib/crewai/src/crewai/auth/oauth2.py | 12 + .../src/crewai/auth/providers/__init__.py | 1 + lib/crewai/src/crewai/auth/providers/auth0.py | 8 + .../crewai/auth/providers/base_provider.py | 8 + .../src/crewai/auth/providers/entra_id.py | 8 + .../src/crewai/auth/providers/keycloak.py | 8 + lib/crewai/src/crewai/auth/providers/okta.py | 8 + .../src/crewai/auth/providers/workos.py | 8 + lib/crewai/src/crewai/auth/token.py | 11 + lib/crewai/src/crewai/auth/token_manager.py | 17 + lib/crewai/src/crewai/auth/utils.py | 8 + lib/crewai/src/crewai/cli/__init__.py | 74 ++++ .../src/crewai/cli/authentication/__init__.py | 4 - .../crewai/cli/authentication/constants.py | 1 - .../authentication/providers/base_provider.py | 33 -- .../src/crewai/cli/authentication/token.py | 13 - lib/crewai/src/crewai/constants.py | 352 +++++++++++++++++ lib/crewai/src/crewai/crew.py | 3 +- .../listeners/tracing/trace_batch_manager.py | 10 +- .../listeners/tracing/trace_listener.py | 4 +- .../crewai/events/listeners/tracing/utils.py | 101 ++--- .../crewai/events/utils/console_formatter.py | 38 +- .../src/crewai/experimental/agent_executor.py | 2 +- .../src/crewai/flow/persistence/decorators.py | 2 +- .../src/crewai/flow/persistence/sqlite.py | 4 +- lib/crewai/src/crewai/flow/utils.py | 2 +- lib/crewai/src/crewai/hooks/llm_hooks.py | 3 +- lib/crewai/src/crewai/hooks/tool_hooks.py | 3 +- lib/crewai/src/crewai/lite_agent.py | 3 +- lib/crewai/src/crewai/llms/base_llm.py | 6 +- lib/crewai/src/crewai/mcp/__init__.py | 1 + lib/crewai/src/crewai/mcp/tool_resolver.py | 5 +- .../storage/kickoff_task_outputs_storage.py | 5 +- .../crewai/memory/storage/lancedb_storage.py | 4 +- .../memory/storage/qdrant_edge_storage.py | 2 +- lib/crewai/src/crewai/plus_api.py | 12 + lib/crewai/src/crewai/rag/chromadb/client.py | 2 +- .../src/crewai/rag/chromadb/constants.py | 2 +- lib/crewai/src/crewai/rag/chromadb/factory.py | 2 +- .../providers/ibm/embedding_callable.py | 2 +- lib/crewai/src/crewai/rag/qdrant/constants.py | 3 +- lib/crewai/src/crewai/settings.py | 30 ++ lib/crewai/src/crewai/state/runtime.py | 2 +- lib/crewai/src/crewai/task.py | 3 +- lib/crewai/src/crewai/tools/tool_usage.py | 2 +- lib/crewai/src/crewai/utilities/__init__.py | 3 +- .../src/crewai/utilities/agent_utils.py | 8 +- lib/crewai/src/crewai/utilities/constants.py | 27 +- lib/crewai/src/crewai/utilities/converter.py | 2 +- .../crewai/{cli => utilities}/crew_chat.py | 113 ++---- .../src/crewai/utilities/file_handler.py | 3 +- lib/crewai/src/crewai/utilities/llm_utils.py | 2 +- lib/crewai/src/crewai/utilities/lock_store.py | 89 +---- lib/crewai/src/crewai/utilities/logger.py | 3 +- lib/crewai/src/crewai/utilities/paths.py | 33 +- lib/crewai/src/crewai/utilities/printer.py | 104 +---- .../utils.py => utilities/project_utils.py} | 355 ++--------------- .../reset_memories.py} | 4 +- lib/crewai/src/crewai/utilities/version.py | 19 +- lib/crewai/src/crewai/version.py | 24 ++ lib/crewai/tests/agents/test_agent.py | 20 +- .../tests/agents/test_async_agent_executor.py | 6 +- .../authentication/providers/test_auth0.py | 4 +- .../authentication/providers/test_entra_id.py | 4 +- .../authentication/providers/test_keycloak.py | 4 +- .../cli/authentication/providers/test_okta.py | 4 +- .../authentication/providers/test_workos.py | 4 +- .../tests/cli/authentication/test_utils.py | 6 +- .../tests/cli/remote_template/test_main.py | 44 +-- lib/crewai/tests/cli/test_cli.py | 269 +------------ lib/crewai/tests/cli/test_config.py | 6 +- lib/crewai/tests/cli/test_constants.py | 2 +- lib/crewai/tests/cli/test_crew_chat.py | 4 +- lib/crewai/tests/cli/test_plus_api.py | 52 +-- lib/crewai/tests/cli/test_replay_from_task.py | 12 +- lib/crewai/tests/cli/test_run_crew.py | 16 +- lib/crewai/tests/cli/test_token_manager.py | 40 +- lib/crewai/tests/cli/test_utils.py | 93 +---- lib/crewai/tests/cli/test_version.py | 52 +-- lib/crewai/tests/llms/openai/test_openai.py | 1 - lib/crewai/tests/mcp/test_amp_mcp.py | 8 +- .../tests/memory/test_unified_memory.py | 2 +- lib/crewai/tests/test_checkpoint.py | 2 +- lib/crewai/tests/test_checkpoint_cli.py | 2 +- lib/crewai/tests/tracing/test_tracing.py | 2 +- lib/crewai/tests/utilities/test_llm_utils.py | 2 +- lib/crewai/tests/utilities/test_lock_store.py | 4 +- pyproject.toml | 16 +- uv.lock | 128 +++++- 249 files changed, 5682 insertions(+), 1822 deletions(-) create mode 100644 lib/cli/README.md create mode 100644 lib/cli/pyproject.toml create mode 100644 lib/cli/src/crewai_cli/__init__.py rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/add_crew_to_flow.py (96%) create mode 100644 lib/cli/src/crewai_cli/authentication/__init__.py create mode 100644 lib/cli/src/crewai_cli/authentication/constants.py create mode 100644 lib/cli/src/crewai_cli/authentication/main.py create mode 100644 lib/cli/src/crewai_cli/authentication/providers/__init__.py create mode 100644 lib/cli/src/crewai_cli/authentication/providers/auth0.py create mode 100644 lib/cli/src/crewai_cli/authentication/providers/base_provider.py create mode 100644 lib/cli/src/crewai_cli/authentication/providers/entra_id.py create mode 100644 lib/cli/src/crewai_cli/authentication/providers/keycloak.py create mode 100644 lib/cli/src/crewai_cli/authentication/providers/okta.py create mode 100644 lib/cli/src/crewai_cli/authentication/providers/workos.py create mode 100644 lib/cli/src/crewai_cli/authentication/token.py create mode 100644 lib/cli/src/crewai_cli/authentication/utils.py rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/checkpoint_cli.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/checkpoint_tui.py (99%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/cli.py (88%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/command.py (88%) create mode 100644 lib/cli/src/crewai_cli/config.py rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/constants.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/create_crew.py (98%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/create_flow.py (98%) create mode 100644 lib/cli/src/crewai_cli/crew_chat.py rename lib/{crewai/src/crewai/cli/authentication/providers => cli/src/crewai_cli/deploy}/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/deploy/main.py (98%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/deploy/validate.py (99%) rename lib/{crewai/src/crewai/cli/deploy => cli/src/crewai_cli/enterprise}/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/enterprise/main.py (95%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/evaluate_crew.py (90%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/git.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/install_crew.py (94%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/kickoff_flow.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/memory_tui.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/organization/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/organization/main.py (97%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/plot_flow.py (100%) create mode 100644 lib/cli/src/crewai_cli/plus_api.py rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/provider.py (99%) rename lib/{crewai/src/crewai/cli/enterprise/__init__.py => cli/src/crewai_cli/py.typed} (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/remote_template/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/remote_template/main.py (99%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/replay_from_task.py (88%) create mode 100644 lib/cli/src/crewai_cli/reset_memories_command.py rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/run_crew.py (94%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/settings/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/settings/main.py (94%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/shared/__init__.py (100%) create mode 100644 lib/cli/src/crewai_cli/shared/token_manager.py create mode 100644 lib/cli/src/crewai_cli/task_outputs.py rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/AGENTS.md (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/.gitignore (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/README.md (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/config/agents.yaml (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/config/tasks.yaml (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/crew.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/knowledge/user_preference.txt (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/main.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/pyproject.toml (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/tools/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/crew/tools/custom_tool.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/.gitignore (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/README.md (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/crews/content_crew/config/agents.yaml (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/crews/content_crew/config/tasks.yaml (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/crews/content_crew/content_crew.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/main.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/pyproject.toml (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/tools/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/flow/tools/custom_tool.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/tool/.gitignore (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/tool/README.md (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/tool/pyproject.toml (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/tool/src/{{folder_name}}/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/templates/tool/src/{{folder_name}}/tool.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/tools/__init__.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/tools/main.py (92%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/train_crew.py (100%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/triggers/__init__.py (59%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/triggers/main.py (98%) rename lib/{crewai/src/crewai/cli => cli/src/crewai_cli}/update_crew.py (99%) create mode 100644 lib/cli/src/crewai_cli/user_data.py create mode 100644 lib/cli/src/crewai_cli/utils.py create mode 100644 lib/cli/src/crewai_cli/version.py rename lib/{crewai/tests/cli/enterprise => cli/tests}/__init__.py (100%) rename lib/{crewai/tests/cli/tools => cli/tests/authentication}/__init__.py (100%) create mode 100644 lib/cli/tests/authentication/providers/__init__.py create mode 100644 lib/cli/tests/authentication/providers/test_auth0.py create mode 100644 lib/cli/tests/authentication/providers/test_entra_id.py create mode 100644 lib/cli/tests/authentication/providers/test_keycloak.py create mode 100644 lib/cli/tests/authentication/providers/test_okta.py create mode 100644 lib/cli/tests/authentication/providers/test_workos.py rename lib/{crewai/tests/cli => cli/tests}/authentication/test_auth_main.py (86%) create mode 100644 lib/cli/tests/authentication/test_utils.py rename lib/{crewai/tests/cli => cli/tests}/deploy/__init__.py (100%) rename lib/{crewai/tests/cli => cli/tests}/deploy/test_deploy_main.py (92%) rename lib/{crewai/tests/cli => cli/tests}/deploy/test_validate.py (97%) create mode 100644 lib/cli/tests/enterprise/__init__.py rename lib/{crewai/tests/cli => cli/tests}/enterprise/test_main.py (88%) rename lib/{crewai/tests/cli => cli/tests}/organization/__init__.py (100%) rename lib/{crewai/tests/cli => cli/tests}/organization/test_main.py (89%) create mode 100644 lib/cli/tests/test_cli.py create mode 100644 lib/cli/tests/test_config.py create mode 100644 lib/cli/tests/test_constants.py rename lib/{crewai/tests/cli => cli/tests}/test_create_crew.py (90%) rename lib/{crewai/tests/cli => cli/tests}/test_crew_test.py (87%) rename lib/{crewai/tests/cli => cli/tests}/test_git.py (98%) create mode 100644 lib/cli/tests/test_plus_api.py rename lib/{crewai/tests/cli => cli/tests}/test_settings_command.py (94%) create mode 100644 lib/cli/tests/test_token_manager.py rename lib/{crewai/tests/cli => cli/tests}/test_train_crew.py (87%) create mode 100644 lib/cli/tests/test_utils.py create mode 100644 lib/cli/tests/test_version.py create mode 100644 lib/cli/tests/tools/__init__.py rename lib/{crewai/tests/cli => cli/tests}/tools/test_main.py (78%) rename lib/{crewai/tests/cli => cli/tests}/triggers/test_main.py (91%) create mode 100644 lib/crewai-core/README.md create mode 100644 lib/crewai-core/pyproject.toml create mode 100644 lib/crewai-core/src/crewai_core/__init__.py create mode 100644 lib/crewai-core/src/crewai_core/auth/__init__.py create mode 100644 lib/crewai-core/src/crewai_core/auth/constants.py rename lib/{crewai/src/crewai/cli/authentication/main.py => crewai-core/src/crewai_core/auth/oauth2.py} (71%) create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/__init__.py rename lib/{crewai/src/crewai/cli/authentication => crewai-core/src/crewai_core/auth}/providers/auth0.py (85%) create mode 100644 lib/crewai-core/src/crewai_core/auth/providers/base_provider.py rename lib/{crewai/src/crewai/cli/authentication => crewai-core/src/crewai_core/auth}/providers/entra_id.py (85%) rename lib/{crewai/src/crewai/cli/authentication => crewai-core/src/crewai_core/auth}/providers/keycloak.py (86%) rename lib/{crewai/src/crewai/cli/authentication => crewai-core/src/crewai_core/auth}/providers/okta.py (88%) rename lib/{crewai/src/crewai/cli/authentication => crewai-core/src/crewai_core/auth}/providers/workos.py (83%) create mode 100644 lib/crewai-core/src/crewai_core/auth/token.py rename lib/{crewai/src/crewai/cli/authentication => crewai-core/src/crewai_core/auth}/utils.py (75%) create mode 100644 lib/crewai-core/src/crewai_core/constants.py create mode 100644 lib/crewai-core/src/crewai_core/lock_store.py create mode 100644 lib/crewai-core/src/crewai_core/paths.py rename lib/{crewai/src/crewai/cli => crewai-core/src/crewai_core}/plus_api.py (96%) create mode 100644 lib/crewai-core/src/crewai_core/printer.py create mode 100644 lib/crewai-core/src/crewai_core/project.py create mode 100644 lib/crewai-core/src/crewai_core/py.typed rename lib/{crewai/src/crewai/cli/config.py => crewai-core/src/crewai_core/settings.py} (79%) create mode 100644 lib/crewai-core/src/crewai_core/telemetry.py rename lib/{crewai/src/crewai/cli/shared => crewai-core/src/crewai_core}/token_manager.py (77%) create mode 100644 lib/crewai-core/src/crewai_core/tool_credentials.py create mode 100644 lib/crewai-core/src/crewai_core/user_data.py rename lib/{crewai/src/crewai/cli => crewai-core/src/crewai_core}/version.py (75%) create mode 100644 lib/crewai-core/tests/__init__.py create mode 100644 lib/crewai-core/tests/test_smoke.py create mode 100644 lib/crewai/src/crewai/auth/__init__.py create mode 100644 lib/crewai/src/crewai/auth/constants.py create mode 100644 lib/crewai/src/crewai/auth/oauth2.py create mode 100644 lib/crewai/src/crewai/auth/providers/__init__.py create mode 100644 lib/crewai/src/crewai/auth/providers/auth0.py create mode 100644 lib/crewai/src/crewai/auth/providers/base_provider.py create mode 100644 lib/crewai/src/crewai/auth/providers/entra_id.py create mode 100644 lib/crewai/src/crewai/auth/providers/keycloak.py create mode 100644 lib/crewai/src/crewai/auth/providers/okta.py create mode 100644 lib/crewai/src/crewai/auth/providers/workos.py create mode 100644 lib/crewai/src/crewai/auth/token.py create mode 100644 lib/crewai/src/crewai/auth/token_manager.py create mode 100644 lib/crewai/src/crewai/auth/utils.py delete mode 100644 lib/crewai/src/crewai/cli/authentication/__init__.py delete mode 100644 lib/crewai/src/crewai/cli/authentication/constants.py delete mode 100644 lib/crewai/src/crewai/cli/authentication/providers/base_provider.py delete mode 100644 lib/crewai/src/crewai/cli/authentication/token.py create mode 100644 lib/crewai/src/crewai/constants.py create mode 100644 lib/crewai/src/crewai/plus_api.py create mode 100644 lib/crewai/src/crewai/settings.py rename lib/crewai/src/crewai/{cli => utilities}/crew_chat.py (81%) rename lib/crewai/src/crewai/{cli/utils.py => utilities/project_utils.py} (63%) rename lib/crewai/src/crewai/{cli/reset_memories_command.py => utilities/reset_memories.py} (96%) create mode 100644 lib/crewai/src/crewai/version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a7ed40a99..bcec74657 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: language: system pass_filenames: true types: [python] - exclude: ^(lib/crewai/src/crewai/cli/templates/|lib/crewai/tests/|lib/crewai-tools/tests/|lib/crewai-files/tests/) + exclude: ^(lib/crewai/src/crewai/cli/templates/|lib/cli/src/crewai_cli/templates/|lib/cli/tests/|lib/crewai/tests/|lib/crewai-tools/tests/|lib/crewai-files/tests/|lib/devtools/tests/) - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.11.3 hooks: diff --git a/conftest.py b/conftest.py index 09852767e..dca182746 100644 --- a/conftest.py +++ b/conftest.py @@ -54,12 +54,13 @@ _original_from_serialized_response = getattr( ) if _original_from_serialized_response is not None: + _from_serialized: Any = _original_from_serialized_response def _patched_from_serialized_response( request: Any, serialized_response: Any, history: Any = None ) -> Any: """Patched version that ensures response._content is properly set.""" - response = _original_from_serialized_response(request, serialized_response, history) + response = _from_serialized(request, serialized_response, history) # Explicitly set _content to avoid ResponseNotRead errors # The content was passed to the constructor but the mocked read() prevents # proper initialization of the internal state @@ -255,7 +256,8 @@ def vcr_cassette_dir(request: Any) -> str: for parent in test_file.parents: if ( - parent.name in ("crewai", "crewai-tools", "crewai-files") + parent.name + in ("crewai", "crewai-tools", "crewai-files", "cli", "crewai-core") and parent.parent.name == "lib" ): package_root = parent diff --git a/lib/cli/README.md b/lib/cli/README.md new file mode 100644 index 000000000..c72a718d1 --- /dev/null +++ b/lib/cli/README.md @@ -0,0 +1,26 @@ +# crewai-cli + +CLI for CrewAI — scaffold, run, deploy and manage AI agent crews without +installing the full framework. + +## Installation + +```bash +pip install crewai-cli +``` + +This pulls in `crewai-core` (shared utilities) but not the `crewai` framework +itself, so commands that don't need a crew loaded — `crewai version`, +`crewai login`, `crewai org list`, `crewai config *`, `crewai traces *`, +`crewai create`, `crewai template *` — work standalone. + +Commands that load a user's crew or flow (`crewai run`, `crewai train`, +`crewai test`, `crewai chat`, `crewai replay`, `crewai reset-memories`, +`crewai deploy push`, `crewai tool publish`) require `crewai` to be installed +in the project's environment. They print a clear error if it is missing. + +To install both at once: + +```bash +pip install crewai[cli] +``` diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml new file mode 100644 index 000000000..193998192 --- /dev/null +++ b/lib/cli/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "crewai-cli" +dynamic = ["version"] +description = "CLI for CrewAI — scaffold, run, deploy and manage AI agent crews." +readme = "README.md" +authors = [ + { name = "Joao Moura", email = "joao@crewai.com" } +] +requires-python = ">=3.10, <3.14" +dependencies = [ + "crewai-core>=1.14.5a2", + "click~=8.1.7", + "pydantic>=2.11.9,<2.13", + "pydantic-settings~=2.10.1", + "appdirs~=1.4.4", + "cryptography>=42.0", + "httpx~=0.28.1", + "pyjwt>=2.9.0,<3", + "rich>=13.7.1", + "tomli~=2.0.2", + "tomli-w~=1.1.0", + "packaging>=23.0", + "python-dotenv>=1.2.2,<2", + "uv~=0.11.6", +] + +[project.urls] +Homepage = "https://crewai.com" +Documentation = "https://docs.crewai.com" +Repository = "https://github.com/crewAIInc/crewAI" + +[project.scripts] +crewai = "crewai_cli.cli:crewai" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/crewai_cli/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/crewai_cli"] diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py new file mode 100644 index 000000000..576c3fdf8 --- /dev/null +++ b/lib/cli/src/crewai_cli/__init__.py @@ -0,0 +1 @@ +__version__ = "1.14.5a2" diff --git a/lib/crewai/src/crewai/cli/add_crew_to_flow.py b/lib/cli/src/crewai_cli/add_crew_to_flow.py similarity index 96% rename from lib/crewai/src/crewai/cli/add_crew_to_flow.py rename to lib/cli/src/crewai_cli/add_crew_to_flow.py index c286b5010..52d3d8e67 100644 --- a/lib/crewai/src/crewai/cli/add_crew_to_flow.py +++ b/lib/cli/src/crewai_cli/add_crew_to_flow.py @@ -1,9 +1,9 @@ from pathlib import Path import click +from crewai_core.printer import PRINTER -from crewai.cli.utils import copy_template -from crewai.utilities.printer import PRINTER +from crewai_cli.utils import copy_template def add_crew_to_flow(crew_name: str) -> None: diff --git a/lib/cli/src/crewai_cli/authentication/__init__.py b/lib/cli/src/crewai_cli/authentication/__init__.py new file mode 100644 index 000000000..dedcc8046 --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/__init__.py @@ -0,0 +1,8 @@ +"""CLI authentication entry point.""" + +from __future__ import annotations + +from crewai_cli.authentication.main import AuthenticationCommand + + +__all__ = ["AuthenticationCommand"] diff --git a/lib/cli/src/crewai_cli/authentication/constants.py b/lib/cli/src/crewai_cli/authentication/constants.py new file mode 100644 index 000000000..b1dae41aa --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/constants.py @@ -0,0 +1,8 @@ +"""Re-export of authentication constants from ``crewai_core.auth.constants``.""" + +from __future__ import annotations + +from crewai_core.auth.constants import ALGORITHMS as ALGORITHMS + + +__all__ = ["ALGORITHMS"] diff --git a/lib/cli/src/crewai_cli/authentication/main.py b/lib/cli/src/crewai_cli/authentication/main.py new file mode 100644 index 000000000..2ef913372 --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/main.py @@ -0,0 +1,60 @@ +"""CLI-side authentication wiring. + +Re-exports the OAuth2 primitives from ``crewai_core.auth`` and overrides the +``_post_login`` hook to also log into the tool repository. +""" + +from __future__ import annotations + +from crewai_core.auth.oauth2 import ( + AuthenticationCommand as _BaseAuthenticationCommand, + Oauth2Settings as Oauth2Settings, + ProviderFactory as ProviderFactory, + console, +) +from crewai_core.settings import Settings + + +__all__ = ["AuthenticationCommand", "Oauth2Settings", "ProviderFactory"] + + +class AuthenticationCommand(_BaseAuthenticationCommand): + """CLI-side login that also signs the user into the tool repository.""" + + def _post_login(self) -> None: + self._login_to_tool_repository() + + def _login_to_tool_repository(self) -> None: + from crewai_cli.tools.main import ToolCommand + + try: + console.print( + "Now logging you in to the Tool Repository... ", + style="bold blue", + end="", + ) + + ToolCommand().login() + + console.print( + "Success!\n", + style="bold green", + ) + + settings = Settings() + + console.print( + f"You are now authenticated to the tool repository for organization [bold cyan]'{settings.org_name if settings.org_name else settings.org_uuid}'[/bold cyan]", + style="green", + ) + except (Exception, SystemExit): + console.print( + "\n[bold yellow]Warning:[/bold yellow] Authentication with the Tool Repository failed.", + style="yellow", + ) + console.print( + "Other features will work normally, but you may experience limitations " + "with downloading and publishing tools." + "\nRun [bold]crewai login[/bold] to try logging in again.\n", + style="yellow", + ) diff --git a/lib/cli/src/crewai_cli/authentication/providers/__init__.py b/lib/cli/src/crewai_cli/authentication/providers/__init__.py new file mode 100644 index 000000000..723579c03 --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/providers/__init__.py @@ -0,0 +1 @@ +"""OAuth2 authentication providers — re-exported from ``crewai_core.auth.providers``.""" diff --git a/lib/cli/src/crewai_cli/authentication/providers/auth0.py b/lib/cli/src/crewai_cli/authentication/providers/auth0.py new file mode 100644 index 000000000..110b4784a --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/providers/auth0.py @@ -0,0 +1,8 @@ +"""Re-export of ``Auth0Provider`` from ``crewai_core.auth.providers.auth0``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.auth0 import Auth0Provider as Auth0Provider + + +__all__ = ["Auth0Provider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/base_provider.py b/lib/cli/src/crewai_cli/authentication/providers/base_provider.py new file mode 100644 index 000000000..d82bfd15a --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/providers/base_provider.py @@ -0,0 +1,8 @@ +"""Re-export of ``BaseProvider`` from ``crewai_core.auth.providers.base_provider``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.base_provider import BaseProvider as BaseProvider + + +__all__ = ["BaseProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/entra_id.py b/lib/cli/src/crewai_cli/authentication/providers/entra_id.py new file mode 100644 index 000000000..1ea10db78 --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/providers/entra_id.py @@ -0,0 +1,8 @@ +"""Re-export of ``EntraIdProvider`` from ``crewai_core.auth.providers.entra_id``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.entra_id import EntraIdProvider as EntraIdProvider + + +__all__ = ["EntraIdProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/keycloak.py b/lib/cli/src/crewai_cli/authentication/providers/keycloak.py new file mode 100644 index 000000000..4bbf0be53 --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/providers/keycloak.py @@ -0,0 +1,8 @@ +"""Re-export of ``KeycloakProvider`` from ``crewai_core.auth.providers.keycloak``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.keycloak import KeycloakProvider as KeycloakProvider + + +__all__ = ["KeycloakProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/okta.py b/lib/cli/src/crewai_cli/authentication/providers/okta.py new file mode 100644 index 000000000..530549be5 --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/providers/okta.py @@ -0,0 +1,8 @@ +"""Re-export of ``OktaProvider`` from ``crewai_core.auth.providers.okta``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.okta import OktaProvider as OktaProvider + + +__all__ = ["OktaProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/providers/workos.py b/lib/cli/src/crewai_cli/authentication/providers/workos.py new file mode 100644 index 000000000..b31c72cae --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/providers/workos.py @@ -0,0 +1,8 @@ +"""Re-export of ``WorkosProvider`` from ``crewai_core.auth.providers.workos``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.workos import WorkosProvider as WorkosProvider + + +__all__ = ["WorkosProvider"] diff --git a/lib/cli/src/crewai_cli/authentication/token.py b/lib/cli/src/crewai_cli/authentication/token.py new file mode 100644 index 000000000..5bb6b656f --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/token.py @@ -0,0 +1,11 @@ +"""Re-exports of authentication token helpers from ``crewai_core.auth.token``.""" + +from __future__ import annotations + +from crewai_core.auth.token import ( + AuthError as AuthError, + get_auth_token as get_auth_token, +) + + +__all__ = ["AuthError", "get_auth_token"] diff --git a/lib/cli/src/crewai_cli/authentication/utils.py b/lib/cli/src/crewai_cli/authentication/utils.py new file mode 100644 index 000000000..700c5d16e --- /dev/null +++ b/lib/cli/src/crewai_cli/authentication/utils.py @@ -0,0 +1,8 @@ +"""Re-export of ``validate_jwt_token`` from ``crewai_core.auth.utils``.""" + +from __future__ import annotations + +from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token + + +__all__ = ["validate_jwt_token"] diff --git a/lib/crewai/src/crewai/cli/checkpoint_cli.py b/lib/cli/src/crewai_cli/checkpoint_cli.py similarity index 100% rename from lib/crewai/src/crewai/cli/checkpoint_cli.py rename to lib/cli/src/crewai_cli/checkpoint_cli.py diff --git a/lib/crewai/src/crewai/cli/checkpoint_tui.py b/lib/cli/src/crewai_cli/checkpoint_tui.py similarity index 99% rename from lib/crewai/src/crewai/cli/checkpoint_tui.py rename to lib/cli/src/crewai_cli/checkpoint_tui.py index 7cc1d6867..b2b154447 100644 --- a/lib/crewai/src/crewai/cli/checkpoint_tui.py +++ b/lib/cli/src/crewai_cli/checkpoint_tui.py @@ -21,7 +21,7 @@ from textual.widgets import ( Tree, ) -from crewai.cli.checkpoint_cli import ( +from crewai_cli.checkpoint_cli import ( _format_size, _is_sqlite, _list_json, diff --git a/lib/crewai/src/crewai/cli/cli.py b/lib/cli/src/crewai_cli/cli.py similarity index 88% rename from lib/crewai/src/crewai/cli/cli.py rename to lib/cli/src/crewai_cli/cli.py index a25fb41d8..9bd1ac396 100644 --- a/lib/crewai/src/crewai/cli/cli.py +++ b/lib/cli/src/crewai_cli/cli.py @@ -1,50 +1,66 @@ +from __future__ import annotations + from importlib.metadata import version as get_version import os import subprocess from typing import Any import click +from crewai_core.token_manager import TokenManager -from crewai.cli.add_crew_to_flow import add_crew_to_flow -from crewai.cli.authentication.main import AuthenticationCommand -from crewai.cli.config import Settings -from crewai.cli.create_crew import create_crew -from crewai.cli.create_flow import create_flow -from crewai.cli.crew_chat import run_chat -from crewai.cli.deploy.main import DeployCommand -from crewai.cli.enterprise.main import EnterpriseConfigureCommand -from crewai.cli.evaluate_crew import evaluate_crew -from crewai.cli.install_crew import install_crew -from crewai.cli.kickoff_flow import kickoff_flow -from crewai.cli.organization.main import OrganizationCommand -from crewai.cli.plot_flow import plot_flow -from crewai.cli.remote_template.main import TemplateCommand -from crewai.cli.replay_from_task import replay_task_command -from crewai.cli.reset_memories_command import reset_memories_command -from crewai.cli.run_crew import run_crew -from crewai.cli.settings.main import SettingsCommand -from crewai.cli.shared.token_manager import TokenManager -from crewai.cli.tools.main import ToolCommand -from crewai.cli.train_crew import train_crew -from crewai.cli.triggers.main import TriggersCommand -from crewai.cli.update_crew import update_crew -from crewai.cli.utils import build_env_with_all_tool_credentials, read_toml -from crewai.memory.storage.kickoff_task_outputs_storage import ( - KickoffTaskOutputsSQLiteStorage, +from crewai_cli.add_crew_to_flow import add_crew_to_flow +from crewai_cli.authentication.main import AuthenticationCommand +from crewai_cli.config import Settings +from crewai_cli.create_crew import create_crew +from crewai_cli.create_flow import create_flow +from crewai_cli.crew_chat import run_chat +from crewai_cli.deploy.main import DeployCommand +from crewai_cli.enterprise.main import EnterpriseConfigureCommand +from crewai_cli.evaluate_crew import evaluate_crew +from crewai_cli.install_crew import install_crew +from crewai_cli.kickoff_flow import kickoff_flow +from crewai_cli.organization.main import OrganizationCommand +from crewai_cli.plot_flow import plot_flow +from crewai_cli.remote_template.main import TemplateCommand +from crewai_cli.replay_from_task import replay_task_command +from crewai_cli.reset_memories_command import reset_memories_command +from crewai_cli.run_crew import run_crew +from crewai_cli.settings.main import SettingsCommand +from crewai_cli.task_outputs import load_task_outputs +from crewai_cli.tools.main import ToolCommand +from crewai_cli.train_crew import train_crew +from crewai_cli.triggers.main import TriggersCommand +from crewai_cli.update_crew import update_crew +from crewai_cli.user_data import ( + _load_user_data, + is_tracing_enabled, + update_user_data, ) +from crewai_cli.utils import build_env_with_all_tool_credentials, read_toml + + +def _get_cli_version() -> str: + """Return the best available version string for the CLI.""" + # Prefer crewai version if installed (keeps existing UX) + try: + return get_version("crewai") + except Exception: # noqa: S110 + pass + try: + return get_version("crewai-cli") + except Exception: + return "unknown" @click.group() -@click.version_option(get_version("crewai")) +@click.version_option(_get_cli_version()) def crewai() -> None: """Top-level command group for crewai.""" @crewai.command( name="uv", - context_settings=dict( - ignore_unknown_options=True, - ), + context_settings={"ignore_unknown_options": True}, ) @click.argument("uv_args", nargs=-1, type=click.UNPROCESSED) def uv(uv_args: tuple[str, ...]) -> None: @@ -105,7 +121,7 @@ def version(tools: bool) -> None: if tools: try: - tools_version = get_version("crewai") + tools_version = get_version("crewai-tools") click.echo(f"crewai tools version: {tools_version}") except Exception: click.echo("crewai tools not installed") @@ -168,12 +184,9 @@ def replay(task_id: str, trained_agents_file: str | None) -> None: @crewai.command() def log_tasks_outputs() -> None: - """ - Retrieve your latest crew.kickoff() task outputs. - """ + """Retrieve your latest crew.kickoff() task outputs.""" try: - storage = KickoffTaskOutputsSQLiteStorage() - tasks = storage.load() + tasks = load_task_outputs() if not tasks: click.echo( @@ -231,11 +244,8 @@ def reset_memories( agent_knowledge: bool, all: bool, ) -> None: - """ - Reset the crew memories (memory, knowledge, agent_knowledge, kickoff_outputs). This will delete all the data saved. - """ + """Reset the crew memories (memory, knowledge, agent_knowledge, kickoff_outputs). This will delete all the data saved.""" try: - # Treat legacy flags as --memory with a deprecation warning if long or short or entities: legacy_used = [ f @@ -302,7 +312,7 @@ def memory( ) -> None: """Open the Memory TUI to browse scopes and recall memories.""" try: - from crewai.cli.memory_tui import MemoryTUI + from crewai_cli.memory_tui import MemoryTUI except ImportError as exc: click.echo( "Textual is required for the memory TUI but could not be imported. " @@ -365,10 +375,10 @@ def test(n_iterations: int, model: str, trained_agents_file: str | None) -> None @crewai.command( - context_settings=dict( - ignore_unknown_options=True, - allow_extra_args=True, - ) + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + } ) @click.pass_context def install(context: click.Context) -> None: @@ -471,7 +481,7 @@ def deploy_validate() -> None: `crewai deploy push` run automatically, without contacting the platform. Exits non-zero if any blocking issues are found. """ - from crewai.cli.deploy.validate import run_validate_command + from crewai_cli.deploy.validate import run_validate_command run_validate_command() @@ -612,14 +622,12 @@ def triggers_run(trigger_path: str) -> None: @crewai.command() def chat() -> None: - """ - Start a conversation with the Crew, collecting user-supplied inputs, + """Start a conversation with the Crew, collecting user-supplied inputs, and using the Chat LLM to generate responses. """ click.secho( "\nStarting a conversation with the Crew\nType 'exit' or Ctrl+C to quit.\n", ) - run_chat() @@ -784,16 +792,14 @@ def traces_enable() -> None: from rich.console import Console from rich.panel import Panel - from crewai.events.listeners.tracing.utils import update_user_data - console = Console() update_user_data({"trace_consent": True, "first_execution_done": True}) panel = Panel( - "✅ Trace collection has been enabled!\n\n" + "✅ Trace collection enabled.\n\n" "Your crew/flow executions will now send traces to CrewAI+.\n" - "Use 'crewai traces disable' to turn off trace collection.", + "Use 'crewai traces disable' to opt out.", title="Traces Enabled", border_style="green", padding=(1, 2), @@ -807,16 +813,16 @@ def traces_disable() -> None: from rich.console import Console from rich.panel import Panel - from crewai.events.listeners.tracing.utils import update_user_data - console = Console() update_user_data({"trace_consent": False, "first_execution_done": True}) panel = Panel( - "❌ Trace collection has been disabled!\n\n" - "Your crew/flow executions will no longer send traces.\n" - "Use 'crewai traces enable' to turn trace collection back on.", + "❌ Trace collection disabled.\n\n" + "Your crew/flow executions will no longer send traces " + "(unless [bold]CREWAI_TRACING_ENABLED=true[/bold] is set in the environment, " + "which overrides the opt-out).\n" + "Use 'crewai traces enable' to opt back in.", title="Traces Disabled", border_style="red", padding=(1, 2), @@ -832,11 +838,6 @@ def traces_status() -> None: from rich.panel import Panel from rich.table import Table - from crewai.events.listeners.tracing.utils import ( - _load_user_data, - is_tracing_enabled, - ) - console = Console() user_data = _load_user_data() @@ -883,13 +884,13 @@ def traces_status() -> None: @click.pass_context def checkpoint(ctx: click.Context, location: str) -> None: """Browse and inspect checkpoints. Launches a TUI when called without a subcommand.""" - from crewai.cli.checkpoint_cli import _detect_location + from crewai_cli.checkpoint_cli import _detect_location location = _detect_location(location) ctx.ensure_object(dict) ctx.obj["location"] = location if ctx.invoked_subcommand is None: - from crewai.cli.checkpoint_tui import run_checkpoint_tui + from crewai_cli.checkpoint_tui import run_checkpoint_tui run_checkpoint_tui(location) @@ -898,7 +899,7 @@ def checkpoint(ctx: click.Context, location: str) -> None: @click.argument("location", default="./.checkpoints") def checkpoint_list(location: str) -> None: """List checkpoints in a directory.""" - from crewai.cli.checkpoint_cli import _detect_location, list_checkpoints + from crewai_cli.checkpoint_cli import _detect_location, list_checkpoints list_checkpoints(_detect_location(location)) @@ -907,7 +908,7 @@ def checkpoint_list(location: str) -> None: @click.argument("path", default="./.checkpoints") def checkpoint_info(path: str) -> None: """Show details of a checkpoint. Pass a file or directory for latest.""" - from crewai.cli.checkpoint_cli import _detect_location, info_checkpoint + from crewai_cli.checkpoint_cli import _detect_location, info_checkpoint info_checkpoint(_detect_location(path)) @@ -917,7 +918,7 @@ def checkpoint_info(path: str) -> None: @click.pass_context def checkpoint_resume(ctx: click.Context, checkpoint_id: str | None) -> None: """Resume from a checkpoint. Defaults to the most recent.""" - from crewai.cli.checkpoint_cli import resume_checkpoint + from crewai_cli.checkpoint_cli import resume_checkpoint resume_checkpoint(ctx.obj["location"], checkpoint_id) @@ -928,7 +929,7 @@ def checkpoint_resume(ctx: click.Context, checkpoint_id: str | None) -> None: @click.pass_context def checkpoint_diff(ctx: click.Context, id1: str, id2: str) -> None: """Compare two checkpoints side-by-side.""" - from crewai.cli.checkpoint_cli import diff_checkpoints + from crewai_cli.checkpoint_cli import diff_checkpoints diff_checkpoints(ctx.obj["location"], id1, id2) @@ -950,7 +951,7 @@ def checkpoint_prune( ctx: click.Context, keep: int | None, older_than: str | None, dry_run: bool ) -> None: """Remove old checkpoints.""" - from crewai.cli.checkpoint_cli import prune_checkpoints + from crewai_cli.checkpoint_cli import prune_checkpoints prune_checkpoints(ctx.obj["location"], keep, older_than, dry_run) diff --git a/lib/crewai/src/crewai/cli/command.py b/lib/cli/src/crewai_cli/command.py similarity index 88% rename from lib/crewai/src/crewai/cli/command.py rename to lib/cli/src/crewai_cli/command.py index 139f69373..229c76323 100644 --- a/lib/crewai/src/crewai/cli/command.py +++ b/lib/cli/src/crewai_cli/command.py @@ -1,11 +1,13 @@ +from __future__ import annotations + import json +from crewai_core.telemetry import Telemetry import httpx from rich.console import Console -from crewai.cli.authentication.token import get_auth_token -from crewai.cli.plus_api import PlusAPI -from crewai.telemetry.telemetry import Telemetry +from crewai_cli.authentication.token import get_auth_token +from crewai_cli.plus_api import PlusAPI console = Console() @@ -32,11 +34,10 @@ class PlusAPIMixin: raise SystemExit from None def _validate_response(self, response: httpx.Response) -> None: - """ - Handle and display error messages from API responses. + """Handle and display error messages from API responses. Args: - response (httpx.Response): The response from the Plus API + response: The response from the Plus API. """ try: json_response = response.json() diff --git a/lib/cli/src/crewai_cli/config.py b/lib/cli/src/crewai_cli/config.py new file mode 100644 index 000000000..d07518b03 --- /dev/null +++ b/lib/cli/src/crewai_cli/config.py @@ -0,0 +1,30 @@ +"""Re-exports of shared settings from ``crewai_core.settings``. + +Kept as a stable import path for the CLI; new code should import from +``crewai_core.settings`` directly. +""" + +from __future__ import annotations + +from crewai_core.settings import ( + CLI_SETTINGS_KEYS as CLI_SETTINGS_KEYS, + DEFAULT_CLI_SETTINGS as DEFAULT_CLI_SETTINGS, + DEFAULT_CONFIG_PATH as DEFAULT_CONFIG_PATH, + HIDDEN_SETTINGS_KEYS as HIDDEN_SETTINGS_KEYS, + READONLY_SETTINGS_KEYS as READONLY_SETTINGS_KEYS, + USER_SETTINGS_KEYS as USER_SETTINGS_KEYS, + Settings as Settings, + get_writable_config_path as get_writable_config_path, +) + + +__all__ = [ + "CLI_SETTINGS_KEYS", + "DEFAULT_CLI_SETTINGS", + "DEFAULT_CONFIG_PATH", + "HIDDEN_SETTINGS_KEYS", + "READONLY_SETTINGS_KEYS", + "USER_SETTINGS_KEYS", + "Settings", + "get_writable_config_path", +] diff --git a/lib/crewai/src/crewai/cli/constants.py b/lib/cli/src/crewai_cli/constants.py similarity index 100% rename from lib/crewai/src/crewai/cli/constants.py rename to lib/cli/src/crewai_cli/constants.py diff --git a/lib/crewai/src/crewai/cli/create_crew.py b/lib/cli/src/crewai_cli/create_crew.py similarity index 98% rename from lib/crewai/src/crewai/cli/create_crew.py rename to lib/cli/src/crewai_cli/create_crew.py index 9bca7c499..001c9eb59 100644 --- a/lib/crewai/src/crewai/cli/create_crew.py +++ b/lib/cli/src/crewai_cli/create_crew.py @@ -5,13 +5,13 @@ import sys import click import tomli -from crewai.cli.constants import ENV_VARS, MODELS -from crewai.cli.provider import ( +from crewai_cli.constants import ENV_VARS, MODELS +from crewai_cli.provider import ( get_provider_data, select_model, select_provider, ) -from crewai.cli.utils import copy_template, load_env_vars, write_env_file +from crewai_cli.utils import copy_template, load_env_vars, write_env_file def get_reserved_script_names() -> set[str]: diff --git a/lib/crewai/src/crewai/cli/create_flow.py b/lib/cli/src/crewai_cli/create_flow.py similarity index 98% rename from lib/crewai/src/crewai/cli/create_flow.py rename to lib/cli/src/crewai_cli/create_flow.py index 3977a8afd..75bd95ed2 100644 --- a/lib/crewai/src/crewai/cli/create_flow.py +++ b/lib/cli/src/crewai_cli/create_flow.py @@ -2,8 +2,7 @@ from pathlib import Path import shutil import click - -from crewai.telemetry import Telemetry +from crewai_core.telemetry import Telemetry def create_flow(name: str) -> None: @@ -18,7 +17,6 @@ def create_flow(name: str) -> None: click.secho(f"Error: Folder {folder_name} already exists.", fg="red") return - # Initialize telemetry telemetry = Telemetry() telemetry.flow_creation_span(class_name) diff --git a/lib/cli/src/crewai_cli/crew_chat.py b/lib/cli/src/crewai_cli/crew_chat.py new file mode 100644 index 000000000..c3f7cf06d --- /dev/null +++ b/lib/cli/src/crewai_cli/crew_chat.py @@ -0,0 +1,23 @@ +"""Wrapper for the crew chat command. + +Delegates to ``crewai.utilities.crew_chat.run_chat`` when the full crewai +package is installed, otherwise prints a helpful error message. +""" + +from __future__ import annotations + +import click + + +def run_chat() -> None: + try: + from crewai.utilities.crew_chat import run_chat as _run_chat + except ImportError: + click.secho( + "The 'chat' command requires the full crewai package.\n" + "Install it with: pip install crewai", + fg="red", + ) + raise SystemExit(1) from None + + _run_chat() diff --git a/lib/crewai/src/crewai/cli/authentication/providers/__init__.py b/lib/cli/src/crewai_cli/deploy/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/authentication/providers/__init__.py rename to lib/cli/src/crewai_cli/deploy/__init__.py diff --git a/lib/crewai/src/crewai/cli/deploy/main.py b/lib/cli/src/crewai_cli/deploy/main.py similarity index 98% rename from lib/crewai/src/crewai/cli/deploy/main.py rename to lib/cli/src/crewai_cli/deploy/main.py index 5a677ba5d..606bf1c16 100644 --- a/lib/crewai/src/crewai/cli/deploy/main.py +++ b/lib/cli/src/crewai_cli/deploy/main.py @@ -2,10 +2,10 @@ from typing import Any from rich.console import Console -from crewai.cli import git -from crewai.cli.command import BaseCommand, PlusAPIMixin -from crewai.cli.deploy.validate import validate_project -from crewai.cli.utils import fetch_and_json_env_file, get_project_name +from crewai_cli import git +from crewai_cli.command import BaseCommand, PlusAPIMixin +from crewai_cli.deploy.validate import validate_project +from crewai_cli.utils import fetch_and_json_env_file, get_project_name console = Console() diff --git a/lib/crewai/src/crewai/cli/deploy/validate.py b/lib/cli/src/crewai_cli/deploy/validate.py similarity index 99% rename from lib/crewai/src/crewai/cli/deploy/validate.py rename to lib/cli/src/crewai_cli/deploy/validate.py index 55246e102..3430e7b0e 100644 --- a/lib/crewai/src/crewai/cli/deploy/validate.py +++ b/lib/cli/src/crewai_cli/deploy/validate.py @@ -40,7 +40,7 @@ from typing import Any from rich.console import Console -from crewai.cli.utils import parse_toml +from crewai_cli.utils import parse_toml console = Console() @@ -438,7 +438,7 @@ class DeployValidator: "import json, sys, traceback, os\n" "os.chdir(sys.argv[1])\n" "try:\n" - " from crewai.cli.utils import get_crews, get_flows\n" + " from crewai.utilities.project_utils import get_crews, get_flows\n" " is_flow = sys.argv[2] == 'flow'\n" " if is_flow:\n" " instances = get_flows()\n" diff --git a/lib/crewai/src/crewai/cli/deploy/__init__.py b/lib/cli/src/crewai_cli/enterprise/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/deploy/__init__.py rename to lib/cli/src/crewai_cli/enterprise/__init__.py diff --git a/lib/crewai/src/crewai/cli/enterprise/main.py b/lib/cli/src/crewai_cli/enterprise/main.py similarity index 95% rename from lib/crewai/src/crewai/cli/enterprise/main.py rename to lib/cli/src/crewai_cli/enterprise/main.py index 2977868f2..61060ac47 100644 --- a/lib/crewai/src/crewai/cli/enterprise/main.py +++ b/lib/cli/src/crewai_cli/enterprise/main.py @@ -4,10 +4,10 @@ from typing import Any, cast import httpx from rich.console import Console -from crewai.cli.authentication.main import Oauth2Settings, ProviderFactory -from crewai.cli.command import BaseCommand -from crewai.cli.settings.main import SettingsCommand -from crewai.utilities.version import get_crewai_version +from crewai_cli.authentication.main import Oauth2Settings, ProviderFactory +from crewai_cli.command import BaseCommand +from crewai_cli.settings.main import SettingsCommand +from crewai_cli.version import get_crewai_version console = Console() diff --git a/lib/crewai/src/crewai/cli/evaluate_crew.py b/lib/cli/src/crewai_cli/evaluate_crew.py similarity index 90% rename from lib/crewai/src/crewai/cli/evaluate_crew.py rename to lib/cli/src/crewai_cli/evaluate_crew.py index 834c3c636..0c6138603 100644 --- a/lib/crewai/src/crewai/cli/evaluate_crew.py +++ b/lib/cli/src/crewai_cli/evaluate_crew.py @@ -1,9 +1,9 @@ import subprocess import click +from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV -from crewai.cli.utils import build_env_with_all_tool_credentials -from crewai.utilities.constants import CREWAI_TRAINED_AGENTS_FILE_ENV +from crewai_cli.utils import build_env_with_all_tool_credentials def evaluate_crew( diff --git a/lib/crewai/src/crewai/cli/git.py b/lib/cli/src/crewai_cli/git.py similarity index 100% rename from lib/crewai/src/crewai/cli/git.py rename to lib/cli/src/crewai_cli/git.py diff --git a/lib/crewai/src/crewai/cli/install_crew.py b/lib/cli/src/crewai_cli/install_crew.py similarity index 94% rename from lib/crewai/src/crewai/cli/install_crew.py rename to lib/cli/src/crewai_cli/install_crew.py index 9e897416a..8e320c78d 100644 --- a/lib/crewai/src/crewai/cli/install_crew.py +++ b/lib/cli/src/crewai_cli/install_crew.py @@ -2,7 +2,7 @@ import subprocess import click -from crewai.cli.utils import build_env_with_all_tool_credentials +from crewai_cli.utils import build_env_with_all_tool_credentials # Be mindful about changing this. diff --git a/lib/crewai/src/crewai/cli/kickoff_flow.py b/lib/cli/src/crewai_cli/kickoff_flow.py similarity index 100% rename from lib/crewai/src/crewai/cli/kickoff_flow.py rename to lib/cli/src/crewai_cli/kickoff_flow.py diff --git a/lib/crewai/src/crewai/cli/memory_tui.py b/lib/cli/src/crewai_cli/memory_tui.py similarity index 100% rename from lib/crewai/src/crewai/cli/memory_tui.py rename to lib/cli/src/crewai_cli/memory_tui.py diff --git a/lib/crewai/src/crewai/cli/organization/__init__.py b/lib/cli/src/crewai_cli/organization/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/organization/__init__.py rename to lib/cli/src/crewai_cli/organization/__init__.py diff --git a/lib/crewai/src/crewai/cli/organization/main.py b/lib/cli/src/crewai_cli/organization/main.py similarity index 97% rename from lib/crewai/src/crewai/cli/organization/main.py rename to lib/cli/src/crewai_cli/organization/main.py index fe61ec202..b8ba86f92 100644 --- a/lib/crewai/src/crewai/cli/organization/main.py +++ b/lib/cli/src/crewai_cli/organization/main.py @@ -2,8 +2,8 @@ from httpx import HTTPStatusError from rich.console import Console from rich.table import Table -from crewai.cli.command import BaseCommand, PlusAPIMixin -from crewai.cli.config import Settings +from crewai_cli.command import BaseCommand, PlusAPIMixin +from crewai_cli.config import Settings console = Console() diff --git a/lib/crewai/src/crewai/cli/plot_flow.py b/lib/cli/src/crewai_cli/plot_flow.py similarity index 100% rename from lib/crewai/src/crewai/cli/plot_flow.py rename to lib/cli/src/crewai_cli/plot_flow.py diff --git a/lib/cli/src/crewai_cli/plus_api.py b/lib/cli/src/crewai_cli/plus_api.py new file mode 100644 index 000000000..708712c8c --- /dev/null +++ b/lib/cli/src/crewai_cli/plus_api.py @@ -0,0 +1,12 @@ +"""Re-export of ``crewai_core.plus_api.PlusAPI``. + +Kept as a stable import path for the CLI; new code should import from +``crewai_core.plus_api`` directly. +""" + +from __future__ import annotations + +from crewai_core.plus_api import PlusAPI as PlusAPI + + +__all__ = ["PlusAPI"] diff --git a/lib/crewai/src/crewai/cli/provider.py b/lib/cli/src/crewai_cli/provider.py similarity index 99% rename from lib/crewai/src/crewai/cli/provider.py rename to lib/cli/src/crewai_cli/provider.py index 1f1e4ec40..cd05b84d3 100644 --- a/lib/crewai/src/crewai/cli/provider.py +++ b/lib/cli/src/crewai_cli/provider.py @@ -10,7 +10,7 @@ import certifi import click import httpx -from crewai.cli.constants import JSON_URL, MODELS, PROVIDERS +from crewai_cli.constants import JSON_URL, MODELS, PROVIDERS def select_choice(prompt_message: str, choices: Sequence[str]) -> str | None: diff --git a/lib/crewai/src/crewai/cli/enterprise/__init__.py b/lib/cli/src/crewai_cli/py.typed similarity index 100% rename from lib/crewai/src/crewai/cli/enterprise/__init__.py rename to lib/cli/src/crewai_cli/py.typed diff --git a/lib/crewai/src/crewai/cli/remote_template/__init__.py b/lib/cli/src/crewai_cli/remote_template/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/remote_template/__init__.py rename to lib/cli/src/crewai_cli/remote_template/__init__.py diff --git a/lib/crewai/src/crewai/cli/remote_template/main.py b/lib/cli/src/crewai_cli/remote_template/main.py similarity index 99% rename from lib/crewai/src/crewai/cli/remote_template/main.py rename to lib/cli/src/crewai_cli/remote_template/main.py index bbd32184f..a7db81191 100644 --- a/lib/crewai/src/crewai/cli/remote_template/main.py +++ b/lib/cli/src/crewai_cli/remote_template/main.py @@ -11,7 +11,7 @@ from rich.console import Console from rich.panel import Panel from rich.text import Text -from crewai.cli.command import BaseCommand +from crewai_cli.command import BaseCommand logger = logging.getLogger(__name__) diff --git a/lib/crewai/src/crewai/cli/replay_from_task.py b/lib/cli/src/crewai_cli/replay_from_task.py similarity index 88% rename from lib/crewai/src/crewai/cli/replay_from_task.py rename to lib/cli/src/crewai_cli/replay_from_task.py index f97b22d8a..76b90cf18 100644 --- a/lib/crewai/src/crewai/cli/replay_from_task.py +++ b/lib/cli/src/crewai_cli/replay_from_task.py @@ -1,9 +1,9 @@ import subprocess import click +from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV -from crewai.cli.utils import build_env_with_all_tool_credentials -from crewai.utilities.constants import CREWAI_TRAINED_AGENTS_FILE_ENV +from crewai_cli.utils import build_env_with_all_tool_credentials def replay_task_command(task_id: str, trained_agents_file: str | None = None) -> None: diff --git a/lib/cli/src/crewai_cli/reset_memories_command.py b/lib/cli/src/crewai_cli/reset_memories_command.py new file mode 100644 index 000000000..9778bf628 --- /dev/null +++ b/lib/cli/src/crewai_cli/reset_memories_command.py @@ -0,0 +1,31 @@ +"""Wrapper for the reset-memories command. + +Delegates to ``crewai.utilities.reset_memories`` when the full crewai +package is installed, otherwise prints a helpful error message. +""" + +from __future__ import annotations + +import click + + +def reset_memories_command( + memory: bool, + knowledge: bool, + agent_knowledge: bool, + kickoff_outputs: bool, + all: bool, +) -> None: + try: + from crewai.utilities.reset_memories import ( + reset_memories_command as _reset, + ) + except ImportError: + click.secho( + "The 'reset-memories' command requires the full crewai package.\n" + "Install it with: pip install crewai", + fg="red", + ) + raise SystemExit(1) from None + + _reset(memory, knowledge, agent_knowledge, kickoff_outputs, all) diff --git a/lib/crewai/src/crewai/cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py similarity index 94% rename from lib/crewai/src/crewai/cli/run_crew.py rename to lib/cli/src/crewai_cli/run_crew.py index 311ab1354..dec85ca06 100644 --- a/lib/crewai/src/crewai/cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -2,11 +2,11 @@ from enum import Enum import subprocess import click +from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV from packaging import version -from crewai.cli.utils import build_env_with_all_tool_credentials, read_toml -from crewai.utilities.constants import CREWAI_TRAINED_AGENTS_FILE_ENV -from crewai.utilities.version import get_crewai_version +from crewai_cli.utils import build_env_with_all_tool_credentials, read_toml +from crewai_cli.version import get_crewai_version class CrewType(Enum): diff --git a/lib/crewai/src/crewai/cli/settings/__init__.py b/lib/cli/src/crewai_cli/settings/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/settings/__init__.py rename to lib/cli/src/crewai_cli/settings/__init__.py diff --git a/lib/crewai/src/crewai/cli/settings/main.py b/lib/cli/src/crewai_cli/settings/main.py similarity index 94% rename from lib/crewai/src/crewai/cli/settings/main.py rename to lib/cli/src/crewai_cli/settings/main.py index a2e520101..b6a942c61 100644 --- a/lib/crewai/src/crewai/cli/settings/main.py +++ b/lib/cli/src/crewai_cli/settings/main.py @@ -5,9 +5,9 @@ from typing import Any from rich.console import Console from rich.table import Table -from crewai.cli.command import BaseCommand -from crewai.cli.config import HIDDEN_SETTINGS_KEYS, READONLY_SETTINGS_KEYS, Settings -from crewai.events.listeners.tracing.utils import _load_user_data +from crewai_cli.command import BaseCommand +from crewai_cli.config import HIDDEN_SETTINGS_KEYS, READONLY_SETTINGS_KEYS, Settings +from crewai_cli.user_data import _load_user_data console = Console() @@ -91,7 +91,7 @@ class SettingsCommand(BaseCommand): style="bold red", ) console.print("Available keys:", style="yellow") - for field_name in Settings.model_fields.keys(): + for field_name in Settings.model_fields: if field_name not in readonly_settings: console.print(f" - {field_name}", style="yellow") raise SystemExit(1) diff --git a/lib/crewai/src/crewai/cli/shared/__init__.py b/lib/cli/src/crewai_cli/shared/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/shared/__init__.py rename to lib/cli/src/crewai_cli/shared/__init__.py diff --git a/lib/cli/src/crewai_cli/shared/token_manager.py b/lib/cli/src/crewai_cli/shared/token_manager.py new file mode 100644 index 000000000..d9f77999a --- /dev/null +++ b/lib/cli/src/crewai_cli/shared/token_manager.py @@ -0,0 +1,12 @@ +"""Re-export of ``crewai_core.token_manager.TokenManager``. + +Kept as a stable import path for the CLI; new code should import from +``crewai_core.token_manager`` directly. +""" + +from __future__ import annotations + +from crewai_core.token_manager import TokenManager as TokenManager + + +__all__ = ["TokenManager"] diff --git a/lib/cli/src/crewai_cli/task_outputs.py b/lib/cli/src/crewai_cli/task_outputs.py new file mode 100644 index 000000000..ec4f1907d --- /dev/null +++ b/lib/cli/src/crewai_cli/task_outputs.py @@ -0,0 +1,67 @@ +"""Lightweight SQLite reader for kickoff task outputs. + +Only used by the ``crewai log-tasks-outputs`` CLI command. Depends solely on +the standard library + *appdirs* so crewai-cli can read stored outputs without +importing the full crewai framework. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +import sqlite3 +from typing import Any + +from crewai_cli.user_data import _db_storage_path + + +logger = logging.getLogger(__name__) + + +def load_task_outputs(db_path: str | None = None) -> list[dict[str, Any]]: + """Return all rows from the kickoff task outputs database.""" + if db_path is None: + db_path = str(Path(_db_storage_path()) / "latest_kickoff_task_outputs.db") + + if not Path(db_path).exists(): + return [] + + try: + with sqlite3.connect(db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(""" + SELECT task_id, expected_output, output, task_index, + inputs, was_replayed, timestamp + FROM latest_kickoff_task_outputs + ORDER BY task_index + """) + rows = cursor.fetchall() + except sqlite3.Error as e: + logger.error("Failed to load task outputs: %s", e) + return [] + + return [ + { + "task_id": row["task_id"], + "expected_output": row["expected_output"], + "output": _safe_json_loads(row["output"]), + "task_index": row["task_index"], + "inputs": _safe_json_loads(row["inputs"]), + "was_replayed": row["was_replayed"], + "timestamp": row["timestamp"], + } + for row in rows + ] + + +def _safe_json_loads(value: str | None) -> Any: + """Decode a JSON column tolerantly: NULL/blank/corrupt → None.""" + if not value: + return None + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError) as e: + logger.warning("Failed to decode JSON column: %s", e) + return None diff --git a/lib/crewai/src/crewai/cli/templates/AGENTS.md b/lib/cli/src/crewai_cli/templates/AGENTS.md similarity index 100% rename from lib/crewai/src/crewai/cli/templates/AGENTS.md rename to lib/cli/src/crewai_cli/templates/AGENTS.md diff --git a/lib/crewai/src/crewai/cli/templates/__init__.py b/lib/cli/src/crewai_cli/templates/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/__init__.py rename to lib/cli/src/crewai_cli/templates/__init__.py diff --git a/lib/crewai/src/crewai/cli/templates/crew/.gitignore b/lib/cli/src/crewai_cli/templates/crew/.gitignore similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/.gitignore rename to lib/cli/src/crewai_cli/templates/crew/.gitignore diff --git a/lib/crewai/src/crewai/cli/templates/crew/README.md b/lib/cli/src/crewai_cli/templates/crew/README.md similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/README.md rename to lib/cli/src/crewai_cli/templates/crew/README.md diff --git a/lib/crewai/src/crewai/cli/templates/crew/__init__.py b/lib/cli/src/crewai_cli/templates/crew/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/__init__.py rename to lib/cli/src/crewai_cli/templates/crew/__init__.py diff --git a/lib/crewai/src/crewai/cli/templates/crew/config/agents.yaml b/lib/cli/src/crewai_cli/templates/crew/config/agents.yaml similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/config/agents.yaml rename to lib/cli/src/crewai_cli/templates/crew/config/agents.yaml diff --git a/lib/crewai/src/crewai/cli/templates/crew/config/tasks.yaml b/lib/cli/src/crewai_cli/templates/crew/config/tasks.yaml similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/config/tasks.yaml rename to lib/cli/src/crewai_cli/templates/crew/config/tasks.yaml diff --git a/lib/crewai/src/crewai/cli/templates/crew/crew.py b/lib/cli/src/crewai_cli/templates/crew/crew.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/crew.py rename to lib/cli/src/crewai_cli/templates/crew/crew.py diff --git a/lib/crewai/src/crewai/cli/templates/crew/knowledge/user_preference.txt b/lib/cli/src/crewai_cli/templates/crew/knowledge/user_preference.txt similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/knowledge/user_preference.txt rename to lib/cli/src/crewai_cli/templates/crew/knowledge/user_preference.txt diff --git a/lib/crewai/src/crewai/cli/templates/crew/main.py b/lib/cli/src/crewai_cli/templates/crew/main.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/main.py rename to lib/cli/src/crewai_cli/templates/crew/main.py diff --git a/lib/crewai/src/crewai/cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/pyproject.toml rename to lib/cli/src/crewai_cli/templates/crew/pyproject.toml diff --git a/lib/crewai/src/crewai/cli/templates/crew/tools/__init__.py b/lib/cli/src/crewai_cli/templates/crew/tools/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/tools/__init__.py rename to lib/cli/src/crewai_cli/templates/crew/tools/__init__.py diff --git a/lib/crewai/src/crewai/cli/templates/crew/tools/custom_tool.py b/lib/cli/src/crewai_cli/templates/crew/tools/custom_tool.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/crew/tools/custom_tool.py rename to lib/cli/src/crewai_cli/templates/crew/tools/custom_tool.py diff --git a/lib/crewai/src/crewai/cli/templates/flow/.gitignore b/lib/cli/src/crewai_cli/templates/flow/.gitignore similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/.gitignore rename to lib/cli/src/crewai_cli/templates/flow/.gitignore diff --git a/lib/crewai/src/crewai/cli/templates/flow/README.md b/lib/cli/src/crewai_cli/templates/flow/README.md similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/README.md rename to lib/cli/src/crewai_cli/templates/flow/README.md diff --git a/lib/crewai/src/crewai/cli/templates/flow/__init__.py b/lib/cli/src/crewai_cli/templates/flow/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/__init__.py rename to lib/cli/src/crewai_cli/templates/flow/__init__.py diff --git a/lib/crewai/src/crewai/cli/templates/flow/crews/content_crew/config/agents.yaml b/lib/cli/src/crewai_cli/templates/flow/crews/content_crew/config/agents.yaml similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/crews/content_crew/config/agents.yaml rename to lib/cli/src/crewai_cli/templates/flow/crews/content_crew/config/agents.yaml diff --git a/lib/crewai/src/crewai/cli/templates/flow/crews/content_crew/config/tasks.yaml b/lib/cli/src/crewai_cli/templates/flow/crews/content_crew/config/tasks.yaml similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/crews/content_crew/config/tasks.yaml rename to lib/cli/src/crewai_cli/templates/flow/crews/content_crew/config/tasks.yaml diff --git a/lib/crewai/src/crewai/cli/templates/flow/crews/content_crew/content_crew.py b/lib/cli/src/crewai_cli/templates/flow/crews/content_crew/content_crew.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/crews/content_crew/content_crew.py rename to lib/cli/src/crewai_cli/templates/flow/crews/content_crew/content_crew.py diff --git a/lib/crewai/src/crewai/cli/templates/flow/main.py b/lib/cli/src/crewai_cli/templates/flow/main.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/main.py rename to lib/cli/src/crewai_cli/templates/flow/main.py diff --git a/lib/crewai/src/crewai/cli/templates/flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/pyproject.toml rename to lib/cli/src/crewai_cli/templates/flow/pyproject.toml diff --git a/lib/crewai/src/crewai/cli/templates/flow/tools/__init__.py b/lib/cli/src/crewai_cli/templates/flow/tools/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/tools/__init__.py rename to lib/cli/src/crewai_cli/templates/flow/tools/__init__.py diff --git a/lib/crewai/src/crewai/cli/templates/flow/tools/custom_tool.py b/lib/cli/src/crewai_cli/templates/flow/tools/custom_tool.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/flow/tools/custom_tool.py rename to lib/cli/src/crewai_cli/templates/flow/tools/custom_tool.py diff --git a/lib/crewai/src/crewai/cli/templates/tool/.gitignore b/lib/cli/src/crewai_cli/templates/tool/.gitignore similarity index 100% rename from lib/crewai/src/crewai/cli/templates/tool/.gitignore rename to lib/cli/src/crewai_cli/templates/tool/.gitignore diff --git a/lib/crewai/src/crewai/cli/templates/tool/README.md b/lib/cli/src/crewai_cli/templates/tool/README.md similarity index 100% rename from lib/crewai/src/crewai/cli/templates/tool/README.md rename to lib/cli/src/crewai_cli/templates/tool/README.md diff --git a/lib/crewai/src/crewai/cli/templates/tool/pyproject.toml b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml similarity index 100% rename from lib/crewai/src/crewai/cli/templates/tool/pyproject.toml rename to lib/cli/src/crewai_cli/templates/tool/pyproject.toml diff --git a/lib/crewai/src/crewai/cli/templates/tool/src/{{folder_name}}/__init__.py b/lib/cli/src/crewai_cli/templates/tool/src/{{folder_name}}/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/tool/src/{{folder_name}}/__init__.py rename to lib/cli/src/crewai_cli/templates/tool/src/{{folder_name}}/__init__.py diff --git a/lib/crewai/src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py b/lib/cli/src/crewai_cli/templates/tool/src/{{folder_name}}/tool.py similarity index 100% rename from lib/crewai/src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py rename to lib/cli/src/crewai_cli/templates/tool/src/{{folder_name}}/tool.py diff --git a/lib/crewai/src/crewai/cli/tools/__init__.py b/lib/cli/src/crewai_cli/tools/__init__.py similarity index 100% rename from lib/crewai/src/crewai/cli/tools/__init__.py rename to lib/cli/src/crewai_cli/tools/__init__.py diff --git a/lib/crewai/src/crewai/cli/tools/main.py b/lib/cli/src/crewai_cli/tools/main.py similarity index 92% rename from lib/crewai/src/crewai/cli/tools/main.py rename to lib/cli/src/crewai_cli/tools/main.py index 67a508e64..76de72c12 100644 --- a/lib/crewai/src/crewai/cli/tools/main.py +++ b/lib/cli/src/crewai_cli/tools/main.py @@ -10,14 +10,12 @@ from typing import Any import click from rich.console import Console -from crewai.cli import git -from crewai.cli.command import BaseCommand, PlusAPIMixin -from crewai.cli.config import Settings -from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL -from crewai.cli.utils import ( +from crewai_cli import git +from crewai_cli.command import BaseCommand, PlusAPIMixin +from crewai_cli.config import Settings +from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL +from crewai_cli.utils import ( build_env_with_tool_repository_credentials, - extract_available_exports, - extract_tools_metadata, get_project_description, get_project_name, get_project_version, @@ -25,12 +23,37 @@ from crewai.cli.utils import ( tree_copy, tree_find_and_replace, ) -from crewai.events.listeners.tracing.utils import get_user_id console = Console() +_REQUIRES_CREWAI_MSG = ( + "[red]This subcommand requires the full crewai package.\n" + "Install it with: pip install crewai[/red]" +) + + +def _require_project_utils() -> Any: + try: + from crewai.utilities import project_utils + + return project_utils + except ImportError: + console.print(_REQUIRES_CREWAI_MSG) + raise SystemExit(1) from None + + +def _require_get_user_id() -> Any: + try: + from crewai.events.listeners.tracing.utils import get_user_id + + return get_user_id + except ImportError: + console.print(_REQUIRES_CREWAI_MSG) + raise SystemExit(1) from None + + class ToolCommand(BaseCommand, PlusAPIMixin): """ A class to handle tool repository related operations for CrewAI projects. @@ -97,7 +120,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin): encoded_tarball = None console.print("[bold blue]Discovering tools from your project...[/bold blue]") - available_exports = extract_available_exports() + project_utils = _require_project_utils() + available_exports = project_utils.extract_available_exports() if available_exports: console.print( @@ -106,7 +130,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): console.print("[bold blue]Extracting tool metadata...[/bold blue]") try: - tools_metadata = extract_tools_metadata() + tools_metadata = project_utils.extract_tools_metadata() except Exception as e: console.print( f"[yellow]Warning: Could not extract tool metadata: {e}[/yellow]\n" @@ -200,6 +224,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): console.print(f"Successfully installed {handle}", style="bold green") def login(self) -> None: + get_user_id = _require_get_user_id() login_response = self.plus_api_client.login_to_tool_repository( user_identifier=get_user_id() ) diff --git a/lib/crewai/src/crewai/cli/train_crew.py b/lib/cli/src/crewai_cli/train_crew.py similarity index 100% rename from lib/crewai/src/crewai/cli/train_crew.py rename to lib/cli/src/crewai_cli/train_crew.py diff --git a/lib/crewai/src/crewai/cli/triggers/__init__.py b/lib/cli/src/crewai_cli/triggers/__init__.py similarity index 59% rename from lib/crewai/src/crewai/cli/triggers/__init__.py rename to lib/cli/src/crewai_cli/triggers/__init__.py index 9158b063d..8b85507ef 100644 --- a/lib/crewai/src/crewai/cli/triggers/__init__.py +++ b/lib/cli/src/crewai_cli/triggers/__init__.py @@ -1,6 +1,6 @@ """Triggers command module for CrewAI CLI.""" -from crewai.cli.triggers.main import TriggersCommand +from crewai_cli.triggers.main import TriggersCommand __all__ = ["TriggersCommand"] diff --git a/lib/crewai/src/crewai/cli/triggers/main.py b/lib/cli/src/crewai_cli/triggers/main.py similarity index 98% rename from lib/crewai/src/crewai/cli/triggers/main.py rename to lib/cli/src/crewai_cli/triggers/main.py index 01cd2a83b..2c081d722 100644 --- a/lib/crewai/src/crewai/cli/triggers/main.py +++ b/lib/cli/src/crewai_cli/triggers/main.py @@ -5,7 +5,7 @@ from typing import Any from rich.console import Console from rich.table import Table -from crewai.cli.command import BaseCommand, PlusAPIMixin +from crewai_cli.command import BaseCommand, PlusAPIMixin console = Console() diff --git a/lib/crewai/src/crewai/cli/update_crew.py b/lib/cli/src/crewai_cli/update_crew.py similarity index 99% rename from lib/crewai/src/crewai/cli/update_crew.py rename to lib/cli/src/crewai_cli/update_crew.py index 343bdebc5..e647a8c7c 100644 --- a/lib/crewai/src/crewai/cli/update_crew.py +++ b/lib/cli/src/crewai_cli/update_crew.py @@ -4,7 +4,7 @@ from typing import Any import tomli_w -from crewai.cli.utils import read_toml +from crewai_cli.utils import read_toml def update_crew() -> None: diff --git a/lib/cli/src/crewai_cli/user_data.py b/lib/cli/src/crewai_cli/user_data.py new file mode 100644 index 000000000..ee95797e8 --- /dev/null +++ b/lib/cli/src/crewai_cli/user_data.py @@ -0,0 +1,22 @@ +"""User-data helpers — re-exported from ``crewai_core.user_data``.""" + +from __future__ import annotations + +from crewai_core.paths import db_storage_path as _db_storage_path +from crewai_core.user_data import ( + _load_user_data as _load_user_data, + _save_user_data as _save_user_data, + has_user_declined_tracing as has_user_declined_tracing, + is_tracing_enabled as is_tracing_enabled, + update_user_data as update_user_data, +) + + +__all__ = [ + "_db_storage_path", + "_load_user_data", + "_save_user_data", + "has_user_declined_tracing", + "is_tracing_enabled", + "update_user_data", +] diff --git a/lib/cli/src/crewai_cli/utils.py b/lib/cli/src/crewai_cli/utils.py new file mode 100644 index 000000000..063c6d14e --- /dev/null +++ b/lib/cli/src/crewai_cli/utils.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import os +from pathlib import Path +import shutil +from typing import Any + +import click +from crewai_core.project import ( + get_project_description as get_project_description, + get_project_name as get_project_name, + get_project_version as get_project_version, + parse_toml as parse_toml, + read_toml as read_toml, +) +from crewai_core.tool_credentials import ( + build_env_with_all_tool_credentials as build_env_with_all_tool_credentials, + build_env_with_tool_repository_credentials as build_env_with_tool_repository_credentials, +) +from rich.console import Console + + +__all__ = [ + "build_env_with_all_tool_credentials", + "build_env_with_tool_repository_credentials", + "copy_template", + "fetch_and_json_env_file", + "get_project_description", + "get_project_name", + "get_project_version", + "load_env_vars", + "parse_toml", + "read_toml", + "tree_copy", + "tree_find_and_replace", + "write_env_file", +] + + +console = Console() + + +def copy_template( + src: Path, dst: Path, name: str, class_name: str, folder_name: str +) -> None: + """Copy a file from src to dst.""" + with open(src, "r") as file: + content = file.read() + + content = content.replace("{{name}}", name) + content = content.replace("{{crew_name}}", class_name) + content = content.replace("{{folder_name}}", folder_name) + + with open(dst, "w") as file: + file.write(content) + + click.secho(f" - Created {dst}", fg="green") + + +def fetch_and_json_env_file(env_file_path: str = ".env") -> dict[str, Any]: + """Fetch the environment variables from a .env file and return them as a dictionary.""" + try: + with open(env_file_path, "r") as f: + env_content = f.read() + + env_dict = {} + for line in env_content.splitlines(): + if line.strip() and not line.strip().startswith("#"): + key, value = line.split("=", 1) + env_dict[key.strip()] = value.strip() + + return env_dict + + except FileNotFoundError: + console.print(f"Error: {env_file_path} not found.", style="bold red") + except Exception as e: + console.print(f"Error reading the .env file: {e}", style="bold red") + + return {} + + +def tree_copy(source: Path, destination: Path) -> None: + """Copies the entire directory structure from the source to the destination.""" + for item in os.listdir(source): + source_item = os.path.join(source, item) + destination_item = os.path.join(destination, item) + if os.path.isdir(source_item): + shutil.copytree(source_item, destination_item) + else: + shutil.copy2(source_item, destination_item) + + +def tree_find_and_replace(directory: Path, find: str, replace: str) -> None: + """Recursively searches through a directory, replacing a target string in + both file contents and filenames with a specified replacement string. + """ + for path, dirs, files in os.walk(os.path.abspath(directory), topdown=False): + for filename in files: + filepath = os.path.join(path, filename) + + with open(filepath, "r", encoding="utf-8", errors="ignore") as file: + contents = file.read() + with open(filepath, "w") as file: + file.write(contents.replace(find, replace)) + + if find in filename: + new_filename = filename.replace(find, replace) + new_filepath = os.path.join(path, new_filename) + os.rename(filepath, new_filepath) + + for dirname in dirs: + if find in dirname: + new_dirname = dirname.replace(find, replace) + new_dirpath = os.path.join(path, new_dirname) + old_dirpath = os.path.join(path, dirname) + os.rename(old_dirpath, new_dirpath) + + +def load_env_vars(folder_path: Path) -> dict[str, Any]: + """Loads environment variables from a .env file in the specified folder path.""" + env_file_path = folder_path / ".env" + env_vars = {} + if env_file_path.exists(): + with open(env_file_path, "r") as file: + for line in file: + key, _, value = line.strip().partition("=") + if key and value: + env_vars[key] = value + return env_vars + + +def write_env_file(folder_path: Path, env_vars: dict[str, Any]) -> None: + """Writes environment variables to a .env file in the specified folder.""" + env_file_path = folder_path / ".env" + with open(env_file_path, "w") as file: + for key, value in env_vars.items(): + file.write(f"{key.upper()}={value}\n") diff --git a/lib/cli/src/crewai_cli/version.py b/lib/cli/src/crewai_cli/version.py new file mode 100644 index 000000000..cd9cc1d48 --- /dev/null +++ b/lib/cli/src/crewai_cli/version.py @@ -0,0 +1,24 @@ +"""Re-exports of version utilities from ``crewai_core.version``. + +Kept as a stable import path for the CLI; new code should import from +``crewai_core.version`` directly. +""" + +from __future__ import annotations + +from crewai_core.version import ( + check_version as check_version, + get_crewai_version as get_crewai_version, + get_latest_version_from_pypi as get_latest_version_from_pypi, + is_current_version_yanked as is_current_version_yanked, + is_newer_version_available as is_newer_version_available, +) + + +__all__ = [ + "check_version", + "get_crewai_version", + "get_latest_version_from_pypi", + "is_current_version_yanked", + "is_newer_version_available", +] diff --git a/lib/crewai/tests/cli/enterprise/__init__.py b/lib/cli/tests/__init__.py similarity index 100% rename from lib/crewai/tests/cli/enterprise/__init__.py rename to lib/cli/tests/__init__.py diff --git a/lib/crewai/tests/cli/tools/__init__.py b/lib/cli/tests/authentication/__init__.py similarity index 100% rename from lib/crewai/tests/cli/tools/__init__.py rename to lib/cli/tests/authentication/__init__.py diff --git a/lib/cli/tests/authentication/providers/__init__.py b/lib/cli/tests/authentication/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/cli/tests/authentication/providers/test_auth0.py b/lib/cli/tests/authentication/providers/test_auth0.py new file mode 100644 index 000000000..c91acf225 --- /dev/null +++ b/lib/cli/tests/authentication/providers/test_auth0.py @@ -0,0 +1,91 @@ +import pytest +from crewai_cli.authentication.main import Oauth2Settings +from crewai_cli.authentication.providers.auth0 import Auth0Provider + + + +class TestAuth0Provider: + + @pytest.fixture(autouse=True) + def setup_method(self): + self.valid_settings = Oauth2Settings( + provider="auth0", + domain="test-domain.auth0.com", + client_id="test-client-id", + audience="test-audience" + ) + self.provider = Auth0Provider(self.valid_settings) + + def test_initialization_with_valid_settings(self): + provider = Auth0Provider(self.valid_settings) + assert provider.settings == self.valid_settings + assert provider.settings.provider == "auth0" + assert provider.settings.domain == "test-domain.auth0.com" + assert provider.settings.client_id == "test-client-id" + assert provider.settings.audience == "test-audience" + + def test_get_authorize_url(self): + expected_url = "https://test-domain.auth0.com/oauth/device/code" + assert self.provider.get_authorize_url() == expected_url + + def test_get_authorize_url_with_different_domain(self): + settings = Oauth2Settings( + provider="auth0", + domain="my-company.auth0.com", + client_id="test-client", + audience="test-audience" + ) + provider = Auth0Provider(settings) + expected_url = "https://my-company.auth0.com/oauth/device/code" + assert provider.get_authorize_url() == expected_url + + def test_get_token_url(self): + expected_url = "https://test-domain.auth0.com/oauth/token" + assert self.provider.get_token_url() == expected_url + + def test_get_token_url_with_different_domain(self): + settings = Oauth2Settings( + provider="auth0", + domain="another-domain.auth0.com", + client_id="test-client", + audience="test-audience" + ) + provider = Auth0Provider(settings) + expected_url = "https://another-domain.auth0.com/oauth/token" + assert provider.get_token_url() == expected_url + + def test_get_jwks_url(self): + expected_url = "https://test-domain.auth0.com/.well-known/jwks.json" + assert self.provider.get_jwks_url() == expected_url + + def test_get_jwks_url_with_different_domain(self): + settings = Oauth2Settings( + provider="auth0", + domain="dev.auth0.com", + client_id="test-client", + audience="test-audience" + ) + provider = Auth0Provider(settings) + expected_url = "https://dev.auth0.com/.well-known/jwks.json" + assert provider.get_jwks_url() == expected_url + + def test_get_issuer(self): + expected_issuer = "https://test-domain.auth0.com/" + assert self.provider.get_issuer() == expected_issuer + + def test_get_issuer_with_different_domain(self): + settings = Oauth2Settings( + provider="auth0", + domain="prod.auth0.com", + client_id="test-client", + audience="test-audience" + ) + provider = Auth0Provider(settings) + expected_issuer = "https://prod.auth0.com/" + assert provider.get_issuer() == expected_issuer + + def test_get_audience(self): + assert self.provider.get_audience() == "test-audience" + + def test_get_client_id(self): + assert self.provider.get_client_id() == "test-client-id" diff --git a/lib/cli/tests/authentication/providers/test_entra_id.py b/lib/cli/tests/authentication/providers/test_entra_id.py new file mode 100644 index 000000000..31ae3d018 --- /dev/null +++ b/lib/cli/tests/authentication/providers/test_entra_id.py @@ -0,0 +1,141 @@ +import pytest + +from crewai_cli.authentication.main import Oauth2Settings +from crewai_cli.authentication.providers.entra_id import EntraIdProvider + + +class TestEntraIdProvider: + @pytest.fixture(autouse=True) + def setup_method(self): + self.valid_settings = Oauth2Settings( + provider="entra_id", + domain="tenant-id-abcdef123456", + client_id="test-client-id", + audience="test-audience", + extra={ + "scope": "openid profile email api://crewai-cli-dev/read" + } + ) + self.provider = EntraIdProvider(self.valid_settings) + + def test_initialization_with_valid_settings(self): + provider = EntraIdProvider(self.valid_settings) + assert provider.settings == self.valid_settings + assert provider.settings.provider == "entra_id" + assert provider.settings.domain == "tenant-id-abcdef123456" + assert provider.settings.client_id == "test-client-id" + assert provider.settings.audience == "test-audience" + + def test_get_authorize_url(self): + expected_url = "https://login.microsoftonline.com/tenant-id-abcdef123456/oauth2/v2.0/devicecode" + assert self.provider.get_authorize_url() == expected_url + + def test_get_authorize_url_with_different_domain(self): + # For EntraID, the domain is the tenant ID. + settings = Oauth2Settings( + provider="entra_id", + domain="my-company.entra.id", + client_id="test-client", + audience="test-audience", + ) + provider = EntraIdProvider(settings) + expected_url = "https://login.microsoftonline.com/my-company.entra.id/oauth2/v2.0/devicecode" + assert provider.get_authorize_url() == expected_url + + def test_get_token_url(self): + expected_url = "https://login.microsoftonline.com/tenant-id-abcdef123456/oauth2/v2.0/token" + assert self.provider.get_token_url() == expected_url + + def test_get_token_url_with_different_domain(self): + # For EntraID, the domain is the tenant ID. + settings = Oauth2Settings( + provider="entra_id", + domain="another-domain.entra.id", + client_id="test-client", + audience="test-audience", + ) + provider = EntraIdProvider(settings) + expected_url = "https://login.microsoftonline.com/another-domain.entra.id/oauth2/v2.0/token" + assert provider.get_token_url() == expected_url + + def test_get_jwks_url(self): + expected_url = "https://login.microsoftonline.com/tenant-id-abcdef123456/discovery/v2.0/keys" + assert self.provider.get_jwks_url() == expected_url + + def test_get_jwks_url_with_different_domain(self): + # For EntraID, the domain is the tenant ID. + settings = Oauth2Settings( + provider="entra_id", + domain="dev.entra.id", + client_id="test-client", + audience="test-audience", + ) + provider = EntraIdProvider(settings) + expected_url = "https://login.microsoftonline.com/dev.entra.id/discovery/v2.0/keys" + assert provider.get_jwks_url() == expected_url + + def test_get_issuer(self): + expected_issuer = "https://login.microsoftonline.com/tenant-id-abcdef123456/v2.0" + assert self.provider.get_issuer() == expected_issuer + + def test_get_issuer_with_different_domain(self): + # For EntraID, the domain is the tenant ID. + settings = Oauth2Settings( + provider="entra_id", + domain="other-tenant-id-xpto", + client_id="test-client", + audience="test-audience", + ) + provider = EntraIdProvider(settings) + expected_issuer = "https://login.microsoftonline.com/other-tenant-id-xpto/v2.0" + assert provider.get_issuer() == expected_issuer + + def test_get_audience(self): + assert self.provider.get_audience() == "test-audience" + + def test_get_audience_assertion_error_when_none(self): + settings = Oauth2Settings( + provider="entra_id", + domain="test-tenant-id", + client_id="test-client-id", + audience=None, + ) + provider = EntraIdProvider(settings) + + with pytest.raises(ValueError, match="Audience is required"): + provider.get_audience() + + def test_get_client_id(self): + assert self.provider.get_client_id() == "test-client-id" + + def test_get_required_fields(self): + assert set(self.provider.get_required_fields()) == set(["scope"]) + + def test_get_oauth_scopes(self): + settings = Oauth2Settings( + provider="entra_id", + domain="tenant-id-abcdef123456", + client_id="test-client-id", + audience="test-audience", + extra={ + "scope": "api://crewai-cli-dev/read" + } + ) + provider = EntraIdProvider(settings) + assert provider.get_oauth_scopes() == ["openid", "profile", "email", "api://crewai-cli-dev/read"] + + def test_get_oauth_scopes_with_multiple_custom_scopes(self): + settings = Oauth2Settings( + provider="entra_id", + domain="tenant-id-abcdef123456", + client_id="test-client-id", + audience="test-audience", + extra={ + "scope": "api://crewai-cli-dev/read api://crewai-cli-dev/write custom-scope1 custom-scope2" + } + ) + provider = EntraIdProvider(settings) + assert provider.get_oauth_scopes() == ["openid", "profile", "email", "api://crewai-cli-dev/read", "api://crewai-cli-dev/write", "custom-scope1", "custom-scope2"] + + def test_base_url(self): + assert self.provider._base_url() == "https://login.microsoftonline.com/tenant-id-abcdef123456" \ No newline at end of file diff --git a/lib/cli/tests/authentication/providers/test_keycloak.py b/lib/cli/tests/authentication/providers/test_keycloak.py new file mode 100644 index 000000000..e9637da6f --- /dev/null +++ b/lib/cli/tests/authentication/providers/test_keycloak.py @@ -0,0 +1,138 @@ +import pytest + +from crewai_cli.authentication.main import Oauth2Settings +from crewai_cli.authentication.providers.keycloak import KeycloakProvider + + +class TestKeycloakProvider: + @pytest.fixture(autouse=True) + def setup_method(self): + self.valid_settings = Oauth2Settings( + provider="keycloak", + domain="keycloak.example.com", + client_id="test-client-id", + audience="test-audience", + extra={ + "realm": "test-realm" + } + ) + self.provider = KeycloakProvider(self.valid_settings) + + def test_initialization_with_valid_settings(self): + provider = KeycloakProvider(self.valid_settings) + assert provider.settings == self.valid_settings + assert provider.settings.provider == "keycloak" + assert provider.settings.domain == "keycloak.example.com" + assert provider.settings.client_id == "test-client-id" + assert provider.settings.audience == "test-audience" + assert provider.settings.extra.get("realm") == "test-realm" + + def test_get_authorize_url(self): + expected_url = "https://keycloak.example.com/realms/test-realm/protocol/openid-connect/auth/device" + assert self.provider.get_authorize_url() == expected_url + + def test_get_authorize_url_with_different_domain(self): + settings = Oauth2Settings( + provider="keycloak", + domain="auth.company.com", + client_id="test-client", + audience="test-audience", + extra={ + "realm": "my-realm" + } + ) + provider = KeycloakProvider(settings) + expected_url = "https://auth.company.com/realms/my-realm/protocol/openid-connect/auth/device" + assert provider.get_authorize_url() == expected_url + + def test_get_token_url(self): + expected_url = "https://keycloak.example.com/realms/test-realm/protocol/openid-connect/token" + assert self.provider.get_token_url() == expected_url + + def test_get_token_url_with_different_domain(self): + settings = Oauth2Settings( + provider="keycloak", + domain="sso.enterprise.com", + client_id="test-client", + audience="test-audience", + extra={ + "realm": "enterprise-realm" + } + ) + provider = KeycloakProvider(settings) + expected_url = "https://sso.enterprise.com/realms/enterprise-realm/protocol/openid-connect/token" + assert provider.get_token_url() == expected_url + + def test_get_jwks_url(self): + expected_url = "https://keycloak.example.com/realms/test-realm/protocol/openid-connect/certs" + assert self.provider.get_jwks_url() == expected_url + + def test_get_jwks_url_with_different_domain(self): + settings = Oauth2Settings( + provider="keycloak", + domain="identity.org", + client_id="test-client", + audience="test-audience", + extra={ + "realm": "org-realm" + } + ) + provider = KeycloakProvider(settings) + expected_url = "https://identity.org/realms/org-realm/protocol/openid-connect/certs" + assert provider.get_jwks_url() == expected_url + + def test_get_issuer(self): + expected_issuer = "https://keycloak.example.com/realms/test-realm" + assert self.provider.get_issuer() == expected_issuer + + def test_get_issuer_with_different_domain(self): + settings = Oauth2Settings( + provider="keycloak", + domain="login.myapp.io", + client_id="test-client", + audience="test-audience", + extra={ + "realm": "app-realm" + } + ) + provider = KeycloakProvider(settings) + expected_issuer = "https://login.myapp.io/realms/app-realm" + assert provider.get_issuer() == expected_issuer + + def test_get_audience(self): + assert self.provider.get_audience() == "test-audience" + + def test_get_client_id(self): + assert self.provider.get_client_id() == "test-client-id" + + def test_get_required_fields(self): + assert self.provider.get_required_fields() == ["realm"] + + def test_oauth2_base_url(self): + assert self.provider._oauth2_base_url() == "https://keycloak.example.com" + + def test_oauth2_base_url_strips_https_prefix(self): + settings = Oauth2Settings( + provider="keycloak", + domain="https://keycloak.example.com", + client_id="test-client-id", + audience="test-audience", + extra={ + "realm": "test-realm" + } + ) + provider = KeycloakProvider(settings) + assert provider._oauth2_base_url() == "https://keycloak.example.com" + + def test_oauth2_base_url_strips_http_prefix(self): + settings = Oauth2Settings( + provider="keycloak", + domain="http://keycloak.example.com", + client_id="test-client-id", + audience="test-audience", + extra={ + "realm": "test-realm" + } + ) + provider = KeycloakProvider(settings) + assert provider._oauth2_base_url() == "https://keycloak.example.com" diff --git a/lib/cli/tests/authentication/providers/test_okta.py b/lib/cli/tests/authentication/providers/test_okta.py new file mode 100644 index 000000000..42d292508 --- /dev/null +++ b/lib/cli/tests/authentication/providers/test_okta.py @@ -0,0 +1,257 @@ +import pytest + +from crewai_cli.authentication.main import Oauth2Settings +from crewai_cli.authentication.providers.okta import OktaProvider + + +class TestOktaProvider: + @pytest.fixture(autouse=True) + def setup_method(self): + self.valid_settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience="test-audience", + ) + self.provider = OktaProvider(self.valid_settings) + + def test_initialization_with_valid_settings(self): + provider = OktaProvider(self.valid_settings) + assert provider.settings == self.valid_settings + assert provider.settings.provider == "okta" + assert provider.settings.domain == "test-domain.okta.com" + assert provider.settings.client_id == "test-client-id" + assert provider.settings.audience == "test-audience" + + def test_get_authorize_url(self): + expected_url = "https://test-domain.okta.com/oauth2/default/v1/device/authorize" + assert self.provider.get_authorize_url() == expected_url + + def test_get_authorize_url_with_different_domain(self): + settings = Oauth2Settings( + provider="okta", + domain="my-company.okta.com", + client_id="test-client", + audience="test-audience", + ) + provider = OktaProvider(settings) + expected_url = "https://my-company.okta.com/oauth2/default/v1/device/authorize" + assert provider.get_authorize_url() == expected_url + + def test_get_authorize_url_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777/v1/device/authorize" + assert provider.get_authorize_url() == expected_url + + def test_get_authorize_url_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/v1/device/authorize" + assert provider.get_authorize_url() == expected_url + + def test_get_token_url(self): + expected_url = "https://test-domain.okta.com/oauth2/default/v1/token" + assert self.provider.get_token_url() == expected_url + + def test_get_token_url_with_different_domain(self): + settings = Oauth2Settings( + provider="okta", + domain="another-domain.okta.com", + client_id="test-client", + audience="test-audience", + ) + provider = OktaProvider(settings) + expected_url = "https://another-domain.okta.com/oauth2/default/v1/token" + assert provider.get_token_url() == expected_url + + def test_get_token_url_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777/v1/token" + assert provider.get_token_url() == expected_url + + def test_get_token_url_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/v1/token" + assert provider.get_token_url() == expected_url + + def test_get_jwks_url(self): + expected_url = "https://test-domain.okta.com/oauth2/default/v1/keys" + assert self.provider.get_jwks_url() == expected_url + + def test_get_jwks_url_with_different_domain(self): + settings = Oauth2Settings( + provider="okta", + domain="dev.okta.com", + client_id="test-client", + audience="test-audience", + ) + provider = OktaProvider(settings) + expected_url = "https://dev.okta.com/oauth2/default/v1/keys" + assert provider.get_jwks_url() == expected_url + + def test_get_jwks_url_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777/v1/keys" + assert provider.get_jwks_url() == expected_url + + def test_get_jwks_url_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/v1/keys" + assert provider.get_jwks_url() == expected_url + + def test_get_issuer(self): + expected_issuer = "https://test-domain.okta.com/oauth2/default" + assert self.provider.get_issuer() == expected_issuer + + def test_get_issuer_with_different_domain(self): + settings = Oauth2Settings( + provider="okta", + domain="prod.okta.com", + client_id="test-client", + audience="test-audience", + ) + provider = OktaProvider(settings) + expected_issuer = "https://prod.okta.com/oauth2/default" + assert provider.get_issuer() == expected_issuer + + def test_get_issuer_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + provider = OktaProvider(settings) + expected_issuer = "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777" + assert provider.get_issuer() == expected_issuer + + def test_get_issuer_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + expected_issuer = "https://test-domain.okta.com" + assert provider.get_issuer() == expected_issuer + + def test_get_audience(self): + assert self.provider.get_audience() == "test-audience" + + def test_get_audience_assertion_error_when_none(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + ) + provider = OktaProvider(settings) + + with pytest.raises(ValueError, match="Audience is required"): + provider.get_audience() + + def test_get_client_id(self): + assert self.provider.get_client_id() == "test-client-id" + + def test_get_required_fields(self): + assert set(self.provider.get_required_fields()) == set(["authorization_server_name", "using_org_auth_server"]) + + def test_oauth2_base_url(self): + assert self.provider._oauth2_base_url() == "https://test-domain.okta.com/oauth2/default" + + def test_oauth2_base_url_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + + provider = OktaProvider(settings) + assert provider._oauth2_base_url() == "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777" + + def test_oauth2_base_url_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + assert provider._oauth2_base_url() == "https://test-domain.okta.com/oauth2" \ No newline at end of file diff --git a/lib/cli/tests/authentication/providers/test_workos.py b/lib/cli/tests/authentication/providers/test_workos.py new file mode 100644 index 000000000..2323e8d95 --- /dev/null +++ b/lib/cli/tests/authentication/providers/test_workos.py @@ -0,0 +1,100 @@ +import pytest +from crewai_cli.authentication.main import Oauth2Settings +from crewai_cli.authentication.providers.workos import WorkosProvider + + +class TestWorkosProvider: + + @pytest.fixture(autouse=True) + def setup_method(self): + self.valid_settings = Oauth2Settings( + provider="workos", + domain="login.company.com", + client_id="test-client-id", + audience="test-audience" + ) + self.provider = WorkosProvider(self.valid_settings) + + def test_initialization_with_valid_settings(self): + provider = WorkosProvider(self.valid_settings) + assert provider.settings == self.valid_settings + assert provider.settings.provider == "workos" + assert provider.settings.domain == "login.company.com" + assert provider.settings.client_id == "test-client-id" + assert provider.settings.audience == "test-audience" + + def test_get_authorize_url(self): + expected_url = "https://login.company.com/oauth2/device_authorization" + assert self.provider.get_authorize_url() == expected_url + + def test_get_authorize_url_with_different_domain(self): + settings = Oauth2Settings( + provider="workos", + domain="login.example.com", + client_id="test-client", + audience="test-audience" + ) + provider = WorkosProvider(settings) + expected_url = "https://login.example.com/oauth2/device_authorization" + assert provider.get_authorize_url() == expected_url + + def test_get_token_url(self): + expected_url = "https://login.company.com/oauth2/token" + assert self.provider.get_token_url() == expected_url + + def test_get_token_url_with_different_domain(self): + settings = Oauth2Settings( + provider="workos", + domain="api.workos.com", + client_id="test-client", + audience="test-audience" + ) + provider = WorkosProvider(settings) + expected_url = "https://api.workos.com/oauth2/token" + assert provider.get_token_url() == expected_url + + def test_get_jwks_url(self): + expected_url = "https://login.company.com/oauth2/jwks" + assert self.provider.get_jwks_url() == expected_url + + def test_get_jwks_url_with_different_domain(self): + settings = Oauth2Settings( + provider="workos", + domain="auth.enterprise.com", + client_id="test-client", + audience="test-audience" + ) + provider = WorkosProvider(settings) + expected_url = "https://auth.enterprise.com/oauth2/jwks" + assert provider.get_jwks_url() == expected_url + + def test_get_issuer(self): + expected_issuer = "https://login.company.com" + assert self.provider.get_issuer() == expected_issuer + + def test_get_issuer_with_different_domain(self): + settings = Oauth2Settings( + provider="workos", + domain="sso.company.com", + client_id="test-client", + audience="test-audience" + ) + provider = WorkosProvider(settings) + expected_issuer = "https://sso.company.com" + assert provider.get_issuer() == expected_issuer + + def test_get_audience(self): + assert self.provider.get_audience() == "test-audience" + + def test_get_audience_fallback_to_default(self): + settings = Oauth2Settings( + provider="workos", + domain="login.company.com", + client_id="test-client-id", + audience=None + ) + provider = WorkosProvider(settings) + assert provider.get_audience() == "" + + def test_get_client_id(self): + assert self.provider.get_client_id() == "test-client-id" diff --git a/lib/crewai/tests/cli/authentication/test_auth_main.py b/lib/cli/tests/authentication/test_auth_main.py similarity index 86% rename from lib/crewai/tests/cli/authentication/test_auth_main.py rename to lib/cli/tests/authentication/test_auth_main.py index 095fea3c4..5dd417d00 100644 --- a/lib/crewai/tests/cli/authentication/test_auth_main.py +++ b/lib/cli/tests/authentication/test_auth_main.py @@ -3,8 +3,8 @@ from unittest.mock import MagicMock, call, patch import pytest import httpx -from crewai.cli.authentication.main import AuthenticationCommand -from crewai.cli.constants import ( +from crewai_cli.authentication.main import AuthenticationCommand +from crewai_cli.constants import ( CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, @@ -13,10 +13,16 @@ from crewai.cli.constants import ( class TestAuthenticationCommand: def setup_method(self): - self.auth_command = AuthenticationCommand() + # Mock Settings so we always use default constants regardless of local config. + with patch("crewai_cli.authentication.main.Settings") as mock_settings: + instance = mock_settings.return_value + instance.oauth2_provider = "workos" + instance.oauth2_domain = CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN + instance.oauth2_client_id = CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID + instance.oauth2_audience = CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE + instance.oauth2_extra = {} + self.auth_command = AuthenticationCommand() - # TODO: these expectations are reading from the actual settings, we should mock them. - # E.g. if you change the client_id locally, this test will fail. @pytest.mark.parametrize( "user_provider,expected_urls", [ @@ -32,12 +38,12 @@ class TestAuthenticationCommand: ), ], ) - @patch("crewai.cli.authentication.main.AuthenticationCommand._get_device_code") + @patch("crewai_cli.authentication.main.AuthenticationCommand._get_device_code") @patch( - "crewai.cli.authentication.main.AuthenticationCommand._display_auth_instructions" + "crewai_cli.authentication.main.AuthenticationCommand._display_auth_instructions" ) - @patch("crewai.cli.authentication.main.AuthenticationCommand._poll_for_token") - @patch("crewai.cli.authentication.main.console.print") + @patch("crewai_cli.authentication.main.AuthenticationCommand._poll_for_token") + @patch("crewai_core.auth.oauth2.console.print") def test_login( self, mock_console_print, @@ -76,8 +82,8 @@ class TestAuthenticationCommand: self.auth_command.oauth2_provider._get_domain() == expected_urls["domain"] ) - @patch("crewai.cli.authentication.main.webbrowser") - @patch("crewai.cli.authentication.main.console.print") + @patch("crewai_core.auth.oauth2.webbrowser") + @patch("crewai_core.auth.oauth2.console.print") def test_display_auth_instructions(self, mock_console_print, mock_webbrowser): device_code_data = { "verification_uri_complete": "https://example.com/auth", @@ -107,8 +113,8 @@ class TestAuthenticationCommand: ], ) @pytest.mark.parametrize("has_expiration", [True, False]) - @patch("crewai.cli.authentication.main.validate_jwt_token") - @patch("crewai.cli.authentication.main.TokenManager.save_tokens") + @patch("crewai_core.auth.oauth2.validate_jwt_token") + @patch("crewai_core.auth.oauth2.TokenManager.save_tokens") def test_validate_and_save_token( self, mock_save_tokens, @@ -117,8 +123,8 @@ class TestAuthenticationCommand: jwt_config, has_expiration, ): - from crewai.cli.authentication.main import Oauth2Settings - from crewai.cli.authentication.providers.workos import WorkosProvider + from crewai_cli.authentication.main import Oauth2Settings + from crewai_cli.authentication.providers.workos import WorkosProvider if user_provider == "workos": self.auth_command.oauth2_provider = WorkosProvider( @@ -156,9 +162,9 @@ class TestAuthenticationCommand: else: mock_save_tokens.assert_called_once_with("test_access_token", 0) - @patch("crewai.cli.tools.main.ToolCommand") - @patch("crewai.cli.authentication.main.Settings") - @patch("crewai.cli.authentication.main.console.print") + @patch("crewai_cli.tools.main.ToolCommand") + @patch("crewai_cli.authentication.main.Settings") + @patch("crewai_core.auth.oauth2.console.print") def test_login_to_tool_repository_success( self, mock_console_print, mock_settings, mock_tool_command ): @@ -189,8 +195,8 @@ class TestAuthenticationCommand: ] mock_console_print.assert_has_calls(expected_calls) - @patch("crewai.cli.tools.main.ToolCommand") - @patch("crewai.cli.authentication.main.console.print") + @patch("crewai_cli.tools.main.ToolCommand") + @patch("crewai_core.auth.oauth2.console.print") def test_login_to_tool_repository_error( self, mock_console_print, mock_tool_command ): @@ -220,7 +226,7 @@ class TestAuthenticationCommand: ] mock_console_print.assert_has_calls(expected_calls) - @patch("crewai.cli.authentication.main.httpx.post") + @patch("crewai_core.auth.oauth2.httpx.post") def test_get_device_code(self, mock_post): mock_response = MagicMock() mock_response.json.return_value = { @@ -256,8 +262,8 @@ class TestAuthenticationCommand: "verification_uri_complete": "https://example.com/auth", } - @patch("crewai.cli.authentication.main.httpx.post") - @patch("crewai.cli.authentication.main.console.print") + @patch("crewai_core.auth.oauth2.httpx.post") + @patch("crewai_core.auth.oauth2.console.print") def test_poll_for_token_success(self, mock_console_print, mock_post): mock_response_success = MagicMock() mock_response_success.status_code = 200 @@ -305,8 +311,8 @@ class TestAuthenticationCommand: ] mock_console_print.assert_has_calls(expected_calls) - @patch("crewai.cli.authentication.main.httpx.post") - @patch("crewai.cli.authentication.main.console.print") + @patch("crewai_core.auth.oauth2.httpx.post") + @patch("crewai_core.auth.oauth2.console.print") def test_poll_for_token_timeout(self, mock_console_print, mock_post): mock_response_pending = MagicMock() mock_response_pending.status_code = 400 @@ -324,7 +330,7 @@ class TestAuthenticationCommand: "Timeout: Failed to get the token. Please try again.", style="bold red" ) - @patch("crewai.cli.authentication.main.httpx.post") + @patch("crewai_core.auth.oauth2.httpx.post") def test_poll_for_token_error(self, mock_post): """Test the method to poll for token (error path).""" # Setup mock to return error diff --git a/lib/cli/tests/authentication/test_utils.py b/lib/cli/tests/authentication/test_utils.py new file mode 100644 index 000000000..d23425717 --- /dev/null +++ b/lib/cli/tests/authentication/test_utils.py @@ -0,0 +1,107 @@ +import unittest +from unittest.mock import MagicMock, patch + +import jwt + +from crewai_cli.authentication.utils import validate_jwt_token + + +@patch("crewai_core.auth.utils.PyJWKClient", return_value=MagicMock()) +@patch("crewai_core.auth.utils.jwt") +class TestUtils(unittest.TestCase): + def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.return_value = {"exp": 1719859200} + + # Create signing key object mock with a .key attribute + mock_pyjwkclient.return_value.get_signing_key_from_jwt.return_value = MagicMock( + key="mock_signing_key" + ) + + jwt_token = "aaaaa.bbbbbb.cccccc" # noqa: S105 + + decoded_token = validate_jwt_token( + jwt_token=jwt_token, + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + mock_jwt.decode.assert_called_with( + jwt_token, + "mock_signing_key", + algorithms=["RS256"], + audience="app_id_xxxx", + issuer="https://mock_issuer", + leeway=10.0, + options={ + "verify_signature": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iat": True, + "require": ["exp", "iat", "iss", "aud", "sub"], + }, + ) + mock_pyjwkclient.assert_called_once_with("https://mock_jwks_url") + self.assertEqual(decoded_token, {"exp": 1719859200}) + + def test_validate_jwt_token_expired(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.ExpiredSignatureError + with self.assertRaises(Exception): # noqa: B017 + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106 + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_invalid_audience(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.InvalidAudienceError + with self.assertRaises(Exception): # noqa: B017 + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106 + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_invalid_issuer(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.InvalidIssuerError + with self.assertRaises(Exception): # noqa: B017 + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106 + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_missing_required_claims( + self, mock_jwt, mock_pyjwkclient + ): + mock_jwt.decode.side_effect = jwt.MissingRequiredClaimError + with self.assertRaises(Exception): # noqa: B017 + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106 + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_jwks_error(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.exceptions.PyJWKClientError + with self.assertRaises(Exception): # noqa: B017 + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106 + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) + + def test_validate_jwt_token_invalid_token(self, mock_jwt, mock_pyjwkclient): + mock_jwt.decode.side_effect = jwt.InvalidTokenError + with self.assertRaises(Exception): # noqa: B017 + validate_jwt_token( + jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106 + jwks_url="https://mock_jwks_url", + issuer="https://mock_issuer", + audience="app_id_xxxx", + ) diff --git a/lib/crewai/tests/cli/deploy/__init__.py b/lib/cli/tests/deploy/__init__.py similarity index 100% rename from lib/crewai/tests/cli/deploy/__init__.py rename to lib/cli/tests/deploy/__init__.py diff --git a/lib/crewai/tests/cli/deploy/test_deploy_main.py b/lib/cli/tests/deploy/test_deploy_main.py similarity index 92% rename from lib/crewai/tests/cli/deploy/test_deploy_main.py rename to lib/cli/tests/deploy/test_deploy_main.py index 9b6e49e1a..4f9fbbc4f 100644 --- a/lib/crewai/tests/cli/deploy/test_deploy_main.py +++ b/lib/cli/tests/deploy/test_deploy_main.py @@ -7,15 +7,20 @@ import pytest import json import httpx -from crewai.cli.deploy.main import DeployCommand -from crewai.cli.utils import parse_toml +from crewai_cli.deploy.main import DeployCommand +from crewai_cli.utils import parse_toml class TestDeployCommand(unittest.TestCase): - @patch("crewai.cli.command.get_auth_token") - @patch("crewai.cli.deploy.main.get_project_name") - @patch("crewai.cli.command.PlusAPI") - def setUp(self, mock_plus_api, mock_get_project_name, mock_get_auth_token): + @patch("crewai_cli.command.get_auth_token") + @patch("crewai_cli.deploy.main.get_project_name") + @patch("crewai_cli.command.PlusAPI") + def setUp( + self, + mock_plus_api, + mock_get_project_name, + mock_get_auth_token, + ): self.mock_get_auth_token = mock_get_auth_token self.mock_get_project_name = mock_get_project_name self.mock_plus_api = mock_plus_api @@ -30,7 +35,7 @@ class TestDeployCommand(unittest.TestCase): self.assertEqual(self.deploy_command.project_name, "test_project") self.mock_plus_api.assert_called_once_with(api_key="test_token") - @patch("crewai.cli.command.get_auth_token") + @patch("crewai_cli.command.get_auth_token") def test_init_failure(self, mock_get_auth_token): mock_get_auth_token.side_effect = Exception("Auth failed") @@ -118,7 +123,7 @@ class TestDeployCommand(unittest.TestCase): ) self.assertIn("2023-01-01 - INFO: Test log", fake_out.getvalue()) - @patch("crewai.cli.deploy.main.DeployCommand._display_deployment_info") + @patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info") def test_deploy_with_uuid(self, mock_display): mock_response = MagicMock() mock_response.status_code = 200 @@ -130,7 +135,7 @@ class TestDeployCommand(unittest.TestCase): self.mock_client.deploy_by_uuid.assert_called_once_with("test-uuid") mock_display.assert_called_once_with({"uuid": "test-uuid"}) - @patch("crewai.cli.deploy.main.DeployCommand._display_deployment_info") + @patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info") def test_deploy_with_project_name(self, mock_display): mock_response = MagicMock() mock_response.status_code = 200 @@ -142,8 +147,8 @@ class TestDeployCommand(unittest.TestCase): self.mock_client.deploy_by_name.assert_called_once_with("test_project") mock_display.assert_called_once_with({"uuid": "test-uuid"}) - @patch("crewai.cli.deploy.main.fetch_and_json_env_file") - @patch("crewai.cli.deploy.main.git.Repository.origin_url") + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository.origin_url") @patch("builtins.input") def test_create_crew(self, mock_input, mock_git_origin_url, mock_fetch_env): mock_fetch_env.return_value = {"ENV_VAR": "value"} @@ -236,7 +241,7 @@ class TestDeployCommand(unittest.TestCase): """, ) def test_get_project_name_python_310(self, mock_open): - from crewai.cli.utils import get_project_name + from crewai_cli.utils import get_project_name project_name = get_project_name() print("project_name", project_name) @@ -255,12 +260,12 @@ class TestDeployCommand(unittest.TestCase): """, ) def test_get_project_name_python_311_plus(self, mock_open): - from crewai.cli.utils import get_project_name + from crewai_cli.utils import get_project_name project_name = get_project_name() self.assertEqual(project_name, "test_project") def test_get_crewai_version(self): - from crewai.cli.version import get_crewai_version + from crewai_cli.version import get_crewai_version assert isinstance(get_crewai_version(), str) diff --git a/lib/crewai/tests/cli/deploy/test_validate.py b/lib/cli/tests/deploy/test_validate.py similarity index 97% rename from lib/crewai/tests/cli/deploy/test_validate.py rename to lib/cli/tests/deploy/test_validate.py index ff8b26376..17ff0fda9 100644 --- a/lib/crewai/tests/cli/deploy/test_validate.py +++ b/lib/cli/tests/deploy/test_validate.py @@ -13,7 +13,7 @@ from unittest.mock import patch import pytest -from crewai.cli.deploy.validate import ( +from crewai_cli.deploy.validate import ( DeployValidator, Severity, normalize_package_name, @@ -413,14 +413,14 @@ def test_create_crew_aborts_on_validation_error(tmp_path: Path) -> None: """`crewai deploy create` must not contact the API when validation fails.""" from unittest.mock import MagicMock, patch as mock_patch - from crewai.cli.deploy.main import DeployCommand + from crewai_cli.deploy.main import DeployCommand with ( - mock_patch("crewai.cli.command.get_auth_token", return_value="tok"), - mock_patch("crewai.cli.deploy.main.get_project_name", return_value="p"), - mock_patch("crewai.cli.command.PlusAPI") as mock_api, + mock_patch("crewai_cli.command.get_auth_token", return_value="tok"), + mock_patch("crewai_cli.deploy.main.get_project_name", return_value="p"), + mock_patch("crewai_cli.command.PlusAPI") as mock_api, mock_patch( - "crewai.cli.deploy.main.validate_project" + "crewai_cli.deploy.main.validate_project" ) as mock_validate, ): mock_validate.return_value = MagicMock(ok=False) diff --git a/lib/cli/tests/enterprise/__init__.py b/lib/cli/tests/enterprise/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/tests/cli/enterprise/test_main.py b/lib/cli/tests/enterprise/test_main.py similarity index 88% rename from lib/crewai/tests/cli/enterprise/test_main.py rename to lib/cli/tests/enterprise/test_main.py index 8a225dc41..988c55ab4 100644 --- a/lib/crewai/tests/cli/enterprise/test_main.py +++ b/lib/cli/tests/enterprise/test_main.py @@ -7,8 +7,8 @@ import json import httpx -from crewai.cli.enterprise.main import EnterpriseConfigureCommand -from crewai.cli.settings.main import SettingsCommand +from crewai_cli.enterprise.main import EnterpriseConfigureCommand +from crewai_cli.settings.main import SettingsCommand import shutil @@ -17,7 +17,7 @@ class TestEnterpriseConfigureCommand(unittest.TestCase): self.test_dir = Path(tempfile.mkdtemp()) self.config_path = self.test_dir / "settings.json" - with patch('crewai.cli.enterprise.main.SettingsCommand') as mock_settings_command_class: + with patch('crewai_cli.enterprise.main.SettingsCommand') as mock_settings_command_class: self.mock_settings_command = Mock(spec=SettingsCommand) mock_settings_command_class.return_value = self.mock_settings_command @@ -26,8 +26,8 @@ class TestEnterpriseConfigureCommand(unittest.TestCase): def tearDown(self): shutil.rmtree(self.test_dir) - @patch('crewai.cli.enterprise.main.httpx.get') - @patch('crewai.cli.enterprise.main.get_crewai_version') + @patch('crewai_cli.enterprise.main.httpx.get') + @patch('crewai_cli.enterprise.main.get_crewai_version') def test_successful_configuration(self, mock_get_version, mock_requests_get): mock_get_version.return_value = "1.0.0" @@ -74,8 +74,8 @@ class TestEnterpriseConfigureCommand(unittest.TestCase): self.assertEqual(call_args[0], key) self.assertEqual(call_args[1], value) - @patch('crewai.cli.enterprise.main.httpx.get') - @patch('crewai.cli.enterprise.main.get_crewai_version') + @patch('crewai_cli.enterprise.main.httpx.get') + @patch('crewai_cli.enterprise.main.get_crewai_version') def test_http_error_handling(self, mock_get_version, mock_requests_get): mock_get_version.return_value = "1.0.0" @@ -90,8 +90,8 @@ class TestEnterpriseConfigureCommand(unittest.TestCase): with self.assertRaises(SystemExit): self.enterprise_command.configure("https://enterprise.example.com") - @patch('crewai.cli.enterprise.main.httpx.get') - @patch('crewai.cli.enterprise.main.get_crewai_version') + @patch('crewai_cli.enterprise.main.httpx.get') + @patch('crewai_cli.enterprise.main.get_crewai_version') def test_invalid_json_response(self, mock_get_version, mock_requests_get): mock_get_version.return_value = "1.0.0" @@ -104,8 +104,8 @@ class TestEnterpriseConfigureCommand(unittest.TestCase): with self.assertRaises(SystemExit): self.enterprise_command.configure("https://enterprise.example.com") - @patch('crewai.cli.enterprise.main.httpx.get') - @patch('crewai.cli.enterprise.main.get_crewai_version') + @patch('crewai_cli.enterprise.main.httpx.get') + @patch('crewai_cli.enterprise.main.get_crewai_version') def test_missing_required_fields(self, mock_get_version, mock_requests_get): mock_get_version.return_value = "1.0.0" @@ -120,8 +120,8 @@ class TestEnterpriseConfigureCommand(unittest.TestCase): with self.assertRaises(SystemExit): self.enterprise_command.configure("https://enterprise.example.com") - @patch('crewai.cli.enterprise.main.httpx.get') - @patch('crewai.cli.enterprise.main.get_crewai_version') + @patch('crewai_cli.enterprise.main.httpx.get') + @patch('crewai_cli.enterprise.main.get_crewai_version') def test_settings_update_error(self, mock_get_version, mock_requests_get): mock_get_version.return_value = "1.0.0" diff --git a/lib/crewai/tests/cli/organization/__init__.py b/lib/cli/tests/organization/__init__.py similarity index 100% rename from lib/crewai/tests/cli/organization/__init__.py rename to lib/cli/tests/organization/__init__.py diff --git a/lib/crewai/tests/cli/organization/test_main.py b/lib/cli/tests/organization/test_main.py similarity index 89% rename from lib/crewai/tests/cli/organization/test_main.py rename to lib/cli/tests/organization/test_main.py index 0db790cbb..36eb99d9f 100644 --- a/lib/crewai/tests/cli/organization/test_main.py +++ b/lib/cli/tests/organization/test_main.py @@ -5,8 +5,8 @@ import pytest from click.testing import CliRunner import httpx -from crewai.cli.organization.main import OrganizationCommand -from crewai.cli.cli import org_list, switch, current +from crewai_cli.organization.main import OrganizationCommand +from crewai_cli.cli import org_list, switch, current @pytest.fixture @@ -23,13 +23,13 @@ def org_command(): @pytest.fixture def mock_settings(): - with patch("crewai.cli.organization.main.Settings") as mock_settings_class: + 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") +@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 @@ -41,7 +41,7 @@ def test_org_list_command(mock_org_command_class, runner): mock_org_instance.list.assert_called_once() -@patch("crewai.cli.cli.OrganizationCommand") +@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 @@ -53,7 +53,7 @@ def test_org_switch_command(mock_org_command_class, runner): mock_org_instance.switch.assert_called_once_with("test-id") -@patch("crewai.cli.cli.OrganizationCommand") +@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 @@ -71,8 +71,8 @@ class TestOrganizationCommand(unittest.TestCase): self.org_command = OrganizationCommand() self.org_command.plus_api_client = MagicMock() - @patch("crewai.cli.organization.main.console") - @patch("crewai.cli.organization.main.Table") + @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() @@ -96,7 +96,7 @@ class TestOrganizationCommand(unittest.TestCase): [call("Org 1", "org-123"), call("Org 2", "org-456")] ) - @patch("crewai.cli.organization.main.console") + @patch("crewai_cli.organization.main.console") def test_list_organizations_empty(self, mock_console): mock_response = MagicMock() mock_response.raise_for_status = MagicMock() @@ -111,7 +111,7 @@ class TestOrganizationCommand(unittest.TestCase): "You don't belong to any organizations yet.", style="yellow" ) - @patch("crewai.cli.organization.main.console") + @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 = ( @@ -126,8 +126,8 @@ class TestOrganizationCommand(unittest.TestCase): "Failed to retrieve organization list: API Error", style="bold red" ) - @patch("crewai.cli.organization.main.console") - @patch("crewai.cli.organization.main.Settings") + @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() @@ -151,7 +151,7 @@ class TestOrganizationCommand(unittest.TestCase): "Successfully switched to Test Org (test-id)", style="bold green" ) - @patch("crewai.cli.organization.main.console") + @patch("crewai_cli.organization.main.console") def test_switch_organization_not_found(self, mock_console): mock_response = MagicMock() mock_response.raise_for_status = MagicMock() @@ -169,8 +169,8 @@ class TestOrganizationCommand(unittest.TestCase): "Organization with id 'non-existent-id' not found.", style="bold red" ) - @patch("crewai.cli.organization.main.console") - @patch("crewai.cli.organization.main.Settings") + @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" @@ -184,8 +184,8 @@ class TestOrganizationCommand(unittest.TestCase): "Currently logged in to organization Test Org (test-id)", style="bold green" ) - @patch("crewai.cli.organization.main.console") - @patch("crewai.cli.organization.main.Settings") + @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 @@ -198,7 +198,7 @@ class TestOrganizationCommand(unittest.TestCase): "You're not currently logged in to any organization.", style="yellow" ) - @patch("crewai.cli.organization.main.console") + @patch("crewai_cli.organization.main.console") def test_list_organizations_unauthorized(self, mock_console): mock_response = MagicMock() mock_http_error = httpx.HTTPStatusError( @@ -218,7 +218,7 @@ class TestOrganizationCommand(unittest.TestCase): style="bold red", ) - @patch("crewai.cli.organization.main.console") + @patch("crewai_cli.organization.main.console") def test_switch_organization_unauthorized(self, mock_console): mock_response = MagicMock() mock_http_error = httpx.HTTPStatusError( diff --git a/lib/cli/tests/test_cli.py b/lib/cli/tests/test_cli.py new file mode 100644 index 000000000..b8e88f333 --- /dev/null +++ b/lib/cli/tests/test_cli.py @@ -0,0 +1,255 @@ +from pathlib import Path +from unittest import mock + +import pytest +from click.testing import CliRunner +from crewai_cli.cli import ( + deploy_create, + deploy_list, + deploy_logs, + deploy_push, + deploy_remove, + deply_status, + flow_add_crew, + login, + reset_memories, + test, + train, + version, +) + + +@pytest.fixture +def runner(): + return CliRunner() + + +@mock.patch("crewai_cli.cli.train_crew") +def test_train_default_iterations(train_crew, runner): + result = runner.invoke(train) + + train_crew.assert_called_once_with(5, "trained_agents_data.pkl") + assert result.exit_code == 0 + assert "Training the Crew for 5 iterations" in result.output + + +@mock.patch("crewai_cli.cli.train_crew") +def test_train_custom_iterations(train_crew, runner): + result = runner.invoke(train, ["--n_iterations", "10"]) + + train_crew.assert_called_once_with(10, "trained_agents_data.pkl") + assert result.exit_code == 0 + assert "Training the Crew for 10 iterations" in result.output + + +@mock.patch("crewai_cli.cli.train_crew") +def test_train_invalid_string_iterations(train_crew, runner): + result = runner.invoke(train, ["--n_iterations", "invalid"]) + + train_crew.assert_not_called() + assert result.exit_code == 2 + assert ( + "Usage: train [OPTIONS]\nTry 'train --help' for help.\n\nError: Invalid value for '-n' / '--n_iterations': 'invalid' is not a valid integer.\n" + in result.output + ) + + +def test_reset_no_memory_flags(runner): + result = runner.invoke( + reset_memories, + ) + assert ( + result.output + == "Please specify at least one memory type to reset using the appropriate flags.\n" + ) + + +def test_version_flag(runner): + result = runner.invoke(version) + + assert result.exit_code == 0 + assert "crewai version:" in result.output + + +def test_version_command(runner): + result = runner.invoke(version) + + assert result.exit_code == 0 + assert "crewai version:" in result.output + + +def test_version_command_with_tools(runner): + result = runner.invoke(version, ["--tools"]) + + assert result.exit_code == 0 + assert "crewai version:" in result.output + assert ( + "crewai tools version:" in result.output + or "crewai tools not installed" in result.output + ) + + +@mock.patch("crewai_cli.cli.evaluate_crew") +def test_test_default_iterations(evaluate_crew, runner): + result = runner.invoke(test) + + evaluate_crew.assert_called_once_with(3, "gpt-4o-mini", trained_agents_file=None) + assert result.exit_code == 0 + assert "Testing the crew for 3 iterations with model gpt-4o-mini" in result.output + + +@mock.patch("crewai_cli.cli.evaluate_crew") +def test_test_custom_iterations(evaluate_crew, runner): + result = runner.invoke(test, ["--n_iterations", "5", "--model", "gpt-4o"]) + + evaluate_crew.assert_called_once_with(5, "gpt-4o", trained_agents_file=None) + assert result.exit_code == 0 + assert "Testing the crew for 5 iterations with model gpt-4o" in result.output + + +@mock.patch("crewai_cli.cli.evaluate_crew") +def test_test_invalid_string_iterations(evaluate_crew, runner): + result = runner.invoke(test, ["--n_iterations", "invalid"]) + + evaluate_crew.assert_not_called() + assert result.exit_code == 2 + assert ( + "Usage: test [OPTIONS]\nTry 'test --help' for help.\n\nError: Invalid value for '-n' / '--n_iterations': 'invalid' is not a valid integer.\n" + in result.output + ) + + +@mock.patch("crewai_cli.cli.AuthenticationCommand") +def test_login(command, runner): + mock_auth = command.return_value + result = runner.invoke(login) + + assert result.exit_code == 0 + mock_auth.login.assert_called_once() + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_create(command, runner): + mock_deploy = command.return_value + result = runner.invoke(deploy_create) + + assert result.exit_code == 0 + mock_deploy.create_crew.assert_called_once() + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_list(command, runner): + mock_deploy = command.return_value + result = runner.invoke(deploy_list) + + assert result.exit_code == 0 + mock_deploy.list_crews.assert_called_once() + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_push(command, runner): + mock_deploy = command.return_value + uuid = "test-uuid" + result = runner.invoke(deploy_push, ["-u", uuid]) + + assert result.exit_code == 0 + mock_deploy.deploy.assert_called_once_with(uuid=uuid, skip_validate=False) + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_push_no_uuid(command, runner): + mock_deploy = command.return_value + result = runner.invoke(deploy_push) + + assert result.exit_code == 0 + mock_deploy.deploy.assert_called_once_with(uuid=None, skip_validate=False) + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_status(command, runner): + mock_deploy = command.return_value + uuid = "test-uuid" + result = runner.invoke(deply_status, ["-u", uuid]) + + assert result.exit_code == 0 + mock_deploy.get_crew_status.assert_called_once_with(uuid=uuid) + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_status_no_uuid(command, runner): + mock_deploy = command.return_value + result = runner.invoke(deply_status) + + assert result.exit_code == 0 + mock_deploy.get_crew_status.assert_called_once_with(uuid=None) + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_logs(command, runner): + mock_deploy = command.return_value + uuid = "test-uuid" + result = runner.invoke(deploy_logs, ["-u", uuid]) + + assert result.exit_code == 0 + mock_deploy.get_crew_logs.assert_called_once_with(uuid=uuid) + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_logs_no_uuid(command, runner): + mock_deploy = command.return_value + result = runner.invoke(deploy_logs) + + assert result.exit_code == 0 + mock_deploy.get_crew_logs.assert_called_once_with(uuid=None) + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_remove(command, runner): + mock_deploy = command.return_value + uuid = "test-uuid" + result = runner.invoke(deploy_remove, ["-u", uuid]) + + assert result.exit_code == 0 + mock_deploy.remove_crew.assert_called_once_with(uuid=uuid) + + +@mock.patch("crewai_cli.cli.DeployCommand") +def test_deploy_remove_no_uuid(command, runner): + mock_deploy = command.return_value + result = runner.invoke(deploy_remove) + + assert result.exit_code == 0 + mock_deploy.remove_crew.assert_called_once_with(uuid=None) + + +@mock.patch("crewai_cli.add_crew_to_flow.create_embedded_crew") +@mock.patch("pathlib.Path.exists", return_value=True) +def test_flow_add_crew(mock_path_exists, mock_create_embedded_crew, runner): + crew_name = "new_crew" + result = runner.invoke(flow_add_crew, [crew_name]) + + assert result.exit_code == 0, f"Command failed with output: {result.output}" + assert f"Adding crew {crew_name} to the flow" in result.output + + mock_create_embedded_crew.assert_called_once() + call_args, call_kwargs = mock_create_embedded_crew.call_args + assert call_args[0] == crew_name + assert "parent_folder" in call_kwargs + assert isinstance(call_kwargs["parent_folder"], Path) + + +def test_add_crew_to_flow_not_in_root(runner): + with mock.patch("pathlib.Path.exists", autospec=True) as mock_exists: + def exists_side_effect(self): + if self.name == "pyproject.toml": + return False + return True + + mock_exists.side_effect = exists_side_effect + + result = runner.invoke(flow_add_crew, ["new_crew"]) + + assert result.exit_code != 0 + assert "This command must be run from the root of a flow project." in str( + result.output + ) diff --git a/lib/cli/tests/test_config.py b/lib/cli/tests/test_config.py new file mode 100644 index 000000000..b8e5ba989 --- /dev/null +++ b/lib/cli/tests/test_config.py @@ -0,0 +1,148 @@ +import json +import shutil +import tempfile +import unittest +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +from crewai_cli.config import ( + CLI_SETTINGS_KEYS, + DEFAULT_CLI_SETTINGS, + USER_SETTINGS_KEYS, + Settings, +) +from crewai_core.token_manager import TokenManager + + +class TestSettings(unittest.TestCase): + def setUp(self): + self.test_dir = Path(tempfile.mkdtemp()) + self.config_path = self.test_dir / "settings.json" + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def test_empty_initialization(self): + settings = Settings(config_path=self.config_path) + self.assertIsNone(settings.tool_repository_username) + self.assertIsNone(settings.tool_repository_password) + + def test_initialization_with_data(self): + settings = Settings( + config_path=self.config_path, tool_repository_username="user1" + ) + self.assertEqual(settings.tool_repository_username, "user1") + self.assertIsNone(settings.tool_repository_password) + + def test_initialization_with_existing_file(self): + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with self.config_path.open("w") as f: + json.dump({"tool_repository_username": "file_user"}, f) + + settings = Settings(config_path=self.config_path) + self.assertEqual(settings.tool_repository_username, "file_user") + + def test_merge_file_and_input_data(self): + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with self.config_path.open("w") as f: + json.dump( + { + "tool_repository_username": "file_user", + "tool_repository_password": "file_pass", + }, + f, + ) + + settings = Settings( + config_path=self.config_path, tool_repository_username="new_user" + ) + self.assertEqual(settings.tool_repository_username, "new_user") + self.assertEqual(settings.tool_repository_password, "file_pass") + + def test_clear_user_settings(self): + user_settings = {key: f"value_for_{key}" for key in USER_SETTINGS_KEYS} + + settings = Settings(config_path=self.config_path, **user_settings) + settings.clear_user_settings() + + for key in user_settings.keys(): + self.assertEqual(getattr(settings, key), None) + + @patch("crewai_core.settings.TokenManager") + def test_reset_settings(self, mock_token_manager): + user_settings = {key: f"value_for_{key}" for key in USER_SETTINGS_KEYS} + cli_settings = {key: f"value_for_{key}" for key in CLI_SETTINGS_KEYS if key != "oauth2_extra"} + cli_settings["oauth2_extra"] = {"scope": "xxx", "other": "yyy"} + + settings = Settings( + config_path=self.config_path, **user_settings, **cli_settings + ) + + mock_token_manager.return_value = MagicMock() + TokenManager().save_tokens( + "aaa.bbb.ccc", (datetime.now() + timedelta(seconds=36000)).timestamp() + ) + + settings.reset() + + for key in user_settings.keys(): + self.assertEqual(getattr(settings, key), None) + for key in cli_settings.keys(): + self.assertEqual(getattr(settings, key), DEFAULT_CLI_SETTINGS.get(key)) + + mock_token_manager.return_value.clear_tokens.assert_called_once() + + def test_dump_new_settings(self): + settings = Settings( + config_path=self.config_path, tool_repository_username="user1" + ) + settings.dump() + + with self.config_path.open("r") as f: + saved_data = json.load(f) + + self.assertEqual(saved_data["tool_repository_username"], "user1") + + def test_update_existing_settings(self): + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with self.config_path.open("w") as f: + json.dump({"existing_setting": "value"}, f) + + settings = Settings( + config_path=self.config_path, tool_repository_username="user1" + ) + settings.dump() + + with self.config_path.open("r") as f: + saved_data = json.load(f) + + self.assertEqual(saved_data["existing_setting"], "value") + self.assertEqual(saved_data["tool_repository_username"], "user1") + + def test_none_values(self): + settings = Settings(config_path=self.config_path, tool_repository_username=None) + settings.dump() + + with self.config_path.open("r") as f: + saved_data = json.load(f) + + self.assertIsNone(saved_data.get("tool_repository_username")) + + def test_invalid_json_in_config(self): + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with self.config_path.open("w") as f: + f.write("invalid json") + + try: + settings = Settings(config_path=self.config_path) + self.assertIsNone(settings.tool_repository_username) + except json.JSONDecodeError: + self.fail("Settings initialization should handle invalid JSON") + + def test_empty_config_file(self): + self.config_path.parent.mkdir(parents=True, exist_ok=True) + self.config_path.touch() + + settings = Settings(config_path=self.config_path) + self.assertIsNone(settings.tool_repository_username) diff --git a/lib/cli/tests/test_constants.py b/lib/cli/tests/test_constants.py new file mode 100644 index 000000000..527ae1dec --- /dev/null +++ b/lib/cli/tests/test_constants.py @@ -0,0 +1,20 @@ +from crewai_cli.constants import ENV_VARS, MODELS, PROVIDERS + + +def test_huggingface_in_providers(): + """Test that Huggingface is in the PROVIDERS list.""" + assert "huggingface" in PROVIDERS + + +def test_huggingface_env_vars(): + """Test that Huggingface environment variables are properly configured.""" + assert "huggingface" in ENV_VARS + assert any( + detail.get("key_name") == "HF_TOKEN" for detail in ENV_VARS["huggingface"] + ) + + +def test_huggingface_models(): + """Test that Huggingface models are properly configured.""" + assert "huggingface" in MODELS + assert len(MODELS["huggingface"]) > 0 diff --git a/lib/crewai/tests/cli/test_create_crew.py b/lib/cli/tests/test_create_crew.py similarity index 90% rename from lib/crewai/tests/cli/test_create_crew.py rename to lib/cli/tests/test_create_crew.py index 478372f7f..83fdbbeeb 100644 --- a/lib/crewai/tests/cli/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -6,7 +6,7 @@ from unittest import mock import pytest from click.testing import CliRunner -from crewai.cli.create_crew import create_crew, create_folder_structure +from crewai_cli.create_crew import create_crew, create_folder_structure @pytest.fixture @@ -89,9 +89,9 @@ def test_create_folder_structure_with_parent_folder(): assert folder_path.exists() -@mock.patch("crewai.cli.create_crew.copy_template") -@mock.patch("crewai.cli.create_crew.write_env_file") -@mock.patch("crewai.cli.create_crew.load_env_vars") +@mock.patch("crewai_cli.create_crew.copy_template") +@mock.patch("crewai_cli.create_crew.write_env_file") +@mock.patch("crewai_cli.create_crew.load_env_vars") def test_create_crew_with_trailing_slash_creates_valid_project( mock_load_env, mock_write_env, mock_copy_template, temp_dir ): @@ -99,7 +99,7 @@ def test_create_crew_with_trailing_slash_creates_valid_project( with tempfile.TemporaryDirectory() as work_dir: with mock.patch( - "crewai.cli.create_crew.create_folder_structure" + "crewai_cli.create_crew.create_folder_structure" ) as mock_create_folder: mock_folder_path = Path(work_dir) / "test_project" mock_create_folder.return_value = ( @@ -123,9 +123,9 @@ def test_create_crew_with_trailing_slash_creates_valid_project( ) -@mock.patch("crewai.cli.create_crew.copy_template") -@mock.patch("crewai.cli.create_crew.write_env_file") -@mock.patch("crewai.cli.create_crew.load_env_vars") +@mock.patch("crewai_cli.create_crew.copy_template") +@mock.patch("crewai_cli.create_crew.write_env_file") +@mock.patch("crewai_cli.create_crew.load_env_vars") def test_create_crew_with_multiple_trailing_slashes( mock_load_env, mock_write_env, mock_copy_template, temp_dir ): @@ -133,7 +133,7 @@ def test_create_crew_with_multiple_trailing_slashes( with tempfile.TemporaryDirectory() as work_dir: with mock.patch( - "crewai.cli.create_crew.create_folder_structure" + "crewai_cli.create_crew.create_folder_structure" ) as mock_create_folder: mock_folder_path = Path(work_dir) / "test_project" mock_create_folder.return_value = ( @@ -147,9 +147,9 @@ def test_create_crew_with_multiple_trailing_slashes( mock_create_folder.assert_called_once_with("test-project///", None) -@mock.patch("crewai.cli.create_crew.copy_template") -@mock.patch("crewai.cli.create_crew.write_env_file") -@mock.patch("crewai.cli.create_crew.load_env_vars") +@mock.patch("crewai_cli.create_crew.copy_template") +@mock.patch("crewai_cli.create_crew.write_env_file") +@mock.patch("crewai_cli.create_crew.load_env_vars") def test_create_crew_normal_name_still_works( mock_load_env, mock_write_env, mock_copy_template, temp_dir ): @@ -157,7 +157,7 @@ def test_create_crew_normal_name_still_works( with tempfile.TemporaryDirectory() as work_dir: with mock.patch( - "crewai.cli.create_crew.create_folder_structure" + "crewai_cli.create_crew.create_folder_structure" ) as mock_create_folder: mock_folder_path = Path(work_dir) / "normal_project" mock_create_folder.return_value = ( @@ -243,9 +243,9 @@ def test_create_folder_structure_validates_names(): shutil.rmtree(folder_path) -@mock.patch("crewai.cli.create_crew.copy_template") -@mock.patch("crewai.cli.create_crew.write_env_file") -@mock.patch("crewai.cli.create_crew.load_env_vars") +@mock.patch("crewai_cli.create_crew.copy_template") +@mock.patch("crewai_cli.create_crew.write_env_file") +@mock.patch("crewai_cli.create_crew.load_env_vars") def test_create_crew_with_parent_folder_and_trailing_slash( mock_load_env, mock_write_env, mock_copy_template, temp_dir ): @@ -313,12 +313,12 @@ def test_create_folder_structure_rejects_reserved_names(): create_folder_structure(capitalized, parent_folder=temp_dir) -@mock.patch("crewai.cli.create_crew.create_folder_structure") -@mock.patch("crewai.cli.create_crew.copy_template") -@mock.patch("crewai.cli.create_crew.load_env_vars") -@mock.patch("crewai.cli.create_crew.get_provider_data") -@mock.patch("crewai.cli.create_crew.select_provider") -@mock.patch("crewai.cli.create_crew.select_model") +@mock.patch("crewai_cli.create_crew.create_folder_structure") +@mock.patch("crewai_cli.create_crew.copy_template") +@mock.patch("crewai_cli.create_crew.load_env_vars") +@mock.patch("crewai_cli.create_crew.get_provider_data") +@mock.patch("crewai_cli.create_crew.select_provider") +@mock.patch("crewai_cli.create_crew.select_model") @mock.patch("click.prompt") def test_env_vars_are_uppercased_in_env_file( mock_prompt, diff --git a/lib/crewai/tests/cli/test_crew_test.py b/lib/cli/tests/test_crew_test.py similarity index 87% rename from lib/crewai/tests/cli/test_crew_test.py rename to lib/cli/tests/test_crew_test.py index 3ebe0c49a..726e4d55d 100644 --- a/lib/crewai/tests/cli/test_crew_test.py +++ b/lib/cli/tests/test_crew_test.py @@ -3,7 +3,7 @@ from unittest import mock import pytest -from crewai.cli import evaluate_crew +from crewai_cli import evaluate_crew @pytest.mark.parametrize( @@ -14,7 +14,7 @@ from crewai.cli import evaluate_crew (10, "gpt-4"), ], ) -@mock.patch("crewai.cli.evaluate_crew.subprocess.run") +@mock.patch("crewai_cli.evaluate_crew.subprocess.run") def test_crew_success(mock_subprocess_run, n_iterations, model): """Test the crew function for successful execution.""" mock_subprocess_run.return_value = subprocess.CompletedProcess( @@ -32,7 +32,7 @@ def test_crew_success(mock_subprocess_run, n_iterations, model): assert result is None -@mock.patch("crewai.cli.evaluate_crew.click") +@mock.patch("crewai_cli.evaluate_crew.click") def test_test_crew_zero_iterations(click): evaluate_crew.evaluate_crew(0, "gpt-4o") click.echo.assert_called_once_with( @@ -41,7 +41,7 @@ def test_test_crew_zero_iterations(click): ) -@mock.patch("crewai.cli.evaluate_crew.click") +@mock.patch("crewai_cli.evaluate_crew.click") def test_test_crew_negative_iterations(click): evaluate_crew.evaluate_crew(-2, "gpt-4o") click.echo.assert_called_once_with( @@ -50,8 +50,8 @@ def test_test_crew_negative_iterations(click): ) -@mock.patch("crewai.cli.evaluate_crew.click") -@mock.patch("crewai.cli.evaluate_crew.subprocess.run") +@mock.patch("crewai_cli.evaluate_crew.click") +@mock.patch("crewai_cli.evaluate_crew.subprocess.run") def test_test_crew_called_process_error(mock_subprocess_run, click): n_iterations = 5 mock_subprocess_run.side_effect = subprocess.CalledProcessError( @@ -80,8 +80,8 @@ def test_test_crew_called_process_error(mock_subprocess_run, click): ) -@mock.patch("crewai.cli.evaluate_crew.click") -@mock.patch("crewai.cli.evaluate_crew.subprocess.run") +@mock.patch("crewai_cli.evaluate_crew.click") +@mock.patch("crewai_cli.evaluate_crew.subprocess.run") def test_test_crew_unexpected_exception(mock_subprocess_run, click): # Arrange n_iterations = 5 @@ -100,7 +100,7 @@ def test_test_crew_unexpected_exception(mock_subprocess_run, click): ) -@mock.patch("crewai.cli.evaluate_crew.subprocess.run") +@mock.patch("crewai_cli.evaluate_crew.subprocess.run") def test_evaluate_crew_sets_trained_agents_env_var(mock_subprocess_run): mock_subprocess_run.return_value = subprocess.CompletedProcess( args=["uv", "run", "test", "1", "gpt-4o"], returncode=0 @@ -111,7 +111,7 @@ def test_evaluate_crew_sets_trained_agents_env_var(mock_subprocess_run): assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom.pkl" -@mock.patch("crewai.cli.evaluate_crew.subprocess.run") +@mock.patch("crewai_cli.evaluate_crew.subprocess.run") def test_evaluate_crew_omits_env_var_without_filename(mock_subprocess_run): mock_subprocess_run.return_value = subprocess.CompletedProcess( args=["uv", "run", "test", "1", "gpt-4o"], returncode=0 diff --git a/lib/crewai/tests/cli/test_git.py b/lib/cli/tests/test_git.py similarity index 98% rename from lib/crewai/tests/cli/test_git.py rename to lib/cli/tests/test_git.py index b77106d3f..c6644990b 100644 --- a/lib/crewai/tests/cli/test_git.py +++ b/lib/cli/tests/test_git.py @@ -1,5 +1,5 @@ import pytest -from crewai.cli.git import Repository +from crewai_cli.git import Repository @pytest.fixture() diff --git a/lib/cli/tests/test_plus_api.py b/lib/cli/tests/test_plus_api.py new file mode 100644 index 000000000..e10a01f70 --- /dev/null +++ b/lib/cli/tests/test_plus_api.py @@ -0,0 +1,359 @@ +import os +import unittest +from unittest.mock import ANY, AsyncMock, MagicMock, patch + +import pytest + +from crewai_cli.plus_api import PlusAPI + + +class TestPlusAPI(unittest.TestCase): + def setUp(self): + self.api_key = "test_api_key" + self.api = PlusAPI(self.api_key) + self.org_uuid = "test-org-uuid" + + def test_init(self): + self.assertEqual(self.api.api_key, self.api_key) + self.assertEqual(self.api.headers["Authorization"], f"Bearer {self.api_key}") + self.assertEqual(self.api.headers["Content-Type"], "application/json") + self.assertIn("CrewAI-CLI/", self.api.headers["User-Agent"]) + self.assertTrue(self.api.headers["X-Crewai-Version"]) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_login_to_tool_repository(self, mock_make_request): + mock_response = MagicMock() + mock_make_request.return_value = mock_response + + response = self.api.login_to_tool_repository() + + mock_make_request.assert_called_once_with( + "POST", "/crewai_plus/api/v1/tools/login", json={} + ) + self.assertEqual(response, mock_response) + + def assert_request_with_org_id( + self, mock_client_instance, method: str, endpoint: str, **kwargs + ): + mock_client_instance.request.assert_called_once_with( + method, + f"{os.getenv('CREWAI_PLUS_URL')}{endpoint}", + headers={ + "Authorization": ANY, + "Content-Type": ANY, + "User-Agent": ANY, + "X-Crewai-Version": ANY, + "X-Crewai-Organization-Id": self.org_uuid, + }, + **kwargs, + ) + + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") + def test_login_to_tool_repository_with_org_uuid( + self, mock_client_class, mock_settings_class + ): + mock_settings = MagicMock() + mock_settings.org_uuid = self.org_uuid + mock_settings.enterprise_base_url = os.getenv('CREWAI_PLUS_URL') + mock_settings_class.return_value = mock_settings + self.api = PlusAPI(self.api_key) + + mock_client_instance = MagicMock() + mock_response = MagicMock() + mock_client_instance.request.return_value = mock_response + mock_client_class.return_value.__enter__.return_value = mock_client_instance + + response = self.api.login_to_tool_repository() + + self.assert_request_with_org_id( + mock_client_instance, "POST", "/crewai_plus/api/v1/tools/login", json={} + ) + self.assertEqual(response, mock_response) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_get_tool(self, mock_make_request): + mock_response = MagicMock() + mock_make_request.return_value = mock_response + + response = self.api.get_tool("test_tool_handle") + mock_make_request.assert_called_once_with( + "GET", "/crewai_plus/api/v1/tools/test_tool_handle" + ) + self.assertEqual(response, mock_response) + + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") + def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class): + mock_settings = MagicMock() + mock_settings.org_uuid = self.org_uuid + mock_settings.enterprise_base_url = os.getenv('CREWAI_PLUS_URL') + mock_settings_class.return_value = mock_settings + self.api = PlusAPI(self.api_key) + + mock_client_instance = MagicMock() + mock_response = MagicMock() + mock_client_instance.request.return_value = mock_response + mock_client_class.return_value.__enter__.return_value = mock_client_instance + + response = self.api.get_tool("test_tool_handle") + + self.assert_request_with_org_id( + mock_client_instance, "GET", "/crewai_plus/api/v1/tools/test_tool_handle" + ) + self.assertEqual(response, mock_response) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_publish_tool(self, mock_make_request): + mock_response = MagicMock() + mock_make_request.return_value = mock_response + handle = "test_tool_handle" + public = True + version = "1.0.0" + description = "Test tool description" + encoded_file = "encoded_test_file" + + response = self.api.publish_tool( + handle, public, version, description, encoded_file + ) + + params = { + "handle": handle, + "public": public, + "version": version, + "file": encoded_file, + "description": description, + "available_exports": None, + "tools_metadata": None, + } + mock_make_request.assert_called_once_with( + "POST", "/crewai_plus/api/v1/tools", json=params + ) + self.assertEqual(response, mock_response) + + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") + def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class): + mock_settings = MagicMock() + mock_settings.org_uuid = self.org_uuid + mock_settings.enterprise_base_url = os.getenv('CREWAI_PLUS_URL') + mock_settings_class.return_value = mock_settings + self.api = PlusAPI(self.api_key) + + mock_client_instance = MagicMock() + mock_response = MagicMock() + mock_client_instance.request.return_value = mock_response + mock_client_class.return_value.__enter__.return_value = mock_client_instance + + handle = "test_tool_handle" + public = True + version = "1.0.0" + description = "Test tool description" + encoded_file = "encoded_test_file" + + response = self.api.publish_tool( + handle, public, version, description, encoded_file + ) + + expected_params = { + "handle": handle, + "public": public, + "version": version, + "file": encoded_file, + "description": description, + "available_exports": None, + "tools_metadata": None, + } + + self.assert_request_with_org_id( + mock_client_instance, "POST", "/crewai_plus/api/v1/tools", json=expected_params + ) + self.assertEqual(response, mock_response) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_publish_tool_without_description(self, mock_make_request): + mock_response = MagicMock() + mock_make_request.return_value = mock_response + handle = "test_tool_handle" + public = False + version = "2.0.0" + description = None + encoded_file = "encoded_test_file" + + response = self.api.publish_tool( + handle, public, version, description, encoded_file + ) + + params = { + "handle": handle, + "public": public, + "version": version, + "file": encoded_file, + "description": description, + "available_exports": None, + "tools_metadata": None, + } + mock_make_request.assert_called_once_with( + "POST", "/crewai_plus/api/v1/tools", json=params + ) + self.assertEqual(response, mock_response) + + @patch("crewai_core.plus_api.httpx.Client") + def test_make_request(self, mock_client_class): + mock_client_instance = MagicMock() + mock_response = MagicMock() + mock_client_instance.request.return_value = mock_response + mock_client_class.return_value.__enter__.return_value = mock_client_instance + + response = self.api._make_request("GET", "test_endpoint") + + mock_client_class.assert_called_once_with(trust_env=False, verify=True) + mock_client_instance.request.assert_called_once_with( + "GET", f"{self.api.base_url}/test_endpoint", headers=self.api.headers + ) + self.assertEqual(response, mock_response) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_deploy_by_name(self, mock_make_request): + self.api.deploy_by_name("test_project") + mock_make_request.assert_called_once_with( + "POST", "/crewai_plus/api/v1/crews/by-name/test_project/deploy" + ) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_deploy_by_uuid(self, mock_make_request): + self.api.deploy_by_uuid("test_uuid") + mock_make_request.assert_called_once_with( + "POST", "/crewai_plus/api/v1/crews/test_uuid/deploy" + ) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_crew_status_by_name(self, mock_make_request): + self.api.crew_status_by_name("test_project") + mock_make_request.assert_called_once_with( + "GET", "/crewai_plus/api/v1/crews/by-name/test_project/status" + ) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_crew_status_by_uuid(self, mock_make_request): + self.api.crew_status_by_uuid("test_uuid") + mock_make_request.assert_called_once_with( + "GET", "/crewai_plus/api/v1/crews/test_uuid/status" + ) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_crew_by_name(self, mock_make_request): + self.api.crew_by_name("test_project") + mock_make_request.assert_called_once_with( + "GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/deployment" + ) + + self.api.crew_by_name("test_project", "custom_log") + mock_make_request.assert_called_with( + "GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/custom_log" + ) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_crew_by_uuid(self, mock_make_request): + self.api.crew_by_uuid("test_uuid") + mock_make_request.assert_called_once_with( + "GET", "/crewai_plus/api/v1/crews/test_uuid/logs/deployment" + ) + + self.api.crew_by_uuid("test_uuid", "custom_log") + mock_make_request.assert_called_with( + "GET", "/crewai_plus/api/v1/crews/test_uuid/logs/custom_log" + ) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_delete_crew_by_name(self, mock_make_request): + self.api.delete_crew_by_name("test_project") + mock_make_request.assert_called_once_with( + "DELETE", "/crewai_plus/api/v1/crews/by-name/test_project" + ) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_delete_crew_by_uuid(self, mock_make_request): + self.api.delete_crew_by_uuid("test_uuid") + mock_make_request.assert_called_once_with( + "DELETE", "/crewai_plus/api/v1/crews/test_uuid" + ) + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_list_crews(self, mock_make_request): + self.api.list_crews() + mock_make_request.assert_called_once_with("GET", "/crewai_plus/api/v1/crews") + + @patch("crewai_core.plus_api.PlusAPI._make_request") + def test_create_crew(self, mock_make_request): + payload = {"name": "test_crew"} + self.api.create_crew(payload) + mock_make_request.assert_called_once_with( + "POST", "/crewai_plus/api/v1/crews", json=payload + ) + + @patch("crewai_core.plus_api.Settings") + @patch.dict(os.environ, {"CREWAI_PLUS_URL": ""}) + def test_custom_base_url(self, mock_settings_class): + mock_settings = MagicMock() + mock_settings.enterprise_base_url = "https://custom-url.com/api" + mock_settings_class.return_value = mock_settings + custom_api = PlusAPI("test_key") + self.assertEqual( + custom_api.base_url, + "https://custom-url.com/api", + ) + + @patch.dict(os.environ, {"CREWAI_PLUS_URL": "https://custom-url-from-env.com"}) + def test_custom_base_url_from_env(self): + custom_api = PlusAPI("test_key") + self.assertEqual( + custom_api.base_url, + "https://custom-url-from-env.com", + ) + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient") +async def test_get_agent(mock_async_client_class): + api = PlusAPI("test_api_key") + mock_response = MagicMock() + mock_client_instance = AsyncMock() + mock_client_instance.get.return_value = mock_response + mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance + + response = await api.get_agent("test_agent_handle") + + mock_client_instance.get.assert_called_once_with( + f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle", + headers=api.headers, + ) + assert response == mock_response + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient") +@patch("crewai_core.plus_api.Settings") +async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class): + org_uuid = "test-org-uuid" + mock_settings = MagicMock() + mock_settings.org_uuid = org_uuid + mock_settings.enterprise_base_url = os.getenv("CREWAI_PLUS_URL") + mock_settings_class.return_value = mock_settings + + api = PlusAPI("test_api_key") + + mock_response = MagicMock() + mock_client_instance = AsyncMock() + mock_client_instance.get.return_value = mock_response + mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance + + response = await api.get_agent("test_agent_handle") + + mock_client_instance.get.assert_called_once_with( + f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle", + headers=api.headers, + ) + assert "X-Crewai-Organization-Id" in api.headers + assert api.headers["X-Crewai-Organization-Id"] == org_uuid + assert response == mock_response diff --git a/lib/crewai/tests/cli/test_settings_command.py b/lib/cli/tests/test_settings_command.py similarity index 94% rename from lib/crewai/tests/cli/test_settings_command.py rename to lib/cli/tests/test_settings_command.py index f15deb821..c788ff453 100644 --- a/lib/crewai/tests/cli/test_settings_command.py +++ b/lib/cli/tests/test_settings_command.py @@ -3,8 +3,8 @@ import unittest from pathlib import Path from unittest.mock import patch, MagicMock, call -from crewai.cli.settings.main import SettingsCommand -from crewai.cli.config import ( +from crewai_cli.settings.main import SettingsCommand +from crewai_cli.config import ( Settings, USER_SETTINGS_KEYS, CLI_SETTINGS_KEYS, @@ -27,8 +27,8 @@ class TestSettingsCommand(unittest.TestCase): def tearDown(self): shutil.rmtree(self.test_dir) - @patch("crewai.cli.settings.main.console") - @patch("crewai.cli.settings.main.Table") + @patch("crewai_cli.settings.main.console") + @patch("crewai_cli.settings.main.Table") def test_list_settings(self, mock_table_class, mock_console): mock_table_instance = MagicMock() mock_table_class.return_value = mock_table_instance diff --git a/lib/cli/tests/test_token_manager.py b/lib/cli/tests/test_token_manager.py new file mode 100644 index 000000000..2d03d8601 --- /dev/null +++ b/lib/cli/tests/test_token_manager.py @@ -0,0 +1,293 @@ +"""Tests for TokenManager with atomic file operations.""" + +import json +import tempfile +import unittest +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import patch + +from cryptography.fernet import Fernet + +from crewai_core.token_manager import TokenManager + + +class TestTokenManager(unittest.TestCase): + """Test cases for TokenManager.""" + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def setUp(self, mock_get_key: unittest.mock.MagicMock) -> None: + """Set up test fixtures.""" + mock_get_key.return_value = Fernet.generate_key() + self.token_manager = TokenManager() + + @patch("crewai_core.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_get_or_create_key_existing( + self, + mock_get_or_create: unittest.mock.MagicMock, + mock_read: unittest.mock.MagicMock, + ) -> None: + """Test that existing key is returned when present.""" + mock_key = Fernet.generate_key() + mock_get_or_create.return_value = mock_key + + token_manager = TokenManager() + result = token_manager.key + + self.assertEqual(result, mock_key) + + def test_get_or_create_key_new(self) -> None: + """Test that new key is created when none exists.""" + mock_key = Fernet.generate_key() + + with ( + patch.object(self.token_manager, "_read_secure_file", return_value=None) as mock_read, + patch.object(self.token_manager, "_atomic_create_secure_file", return_value=True) as mock_atomic_create, + patch("crewai_core.token_manager.Fernet.generate_key", return_value=mock_key) as mock_generate, + ): + result = self.token_manager._get_or_create_key() + + self.assertEqual(result, mock_key) + mock_read.assert_called_with("secret.key") + mock_generate.assert_called_once() + mock_atomic_create.assert_called_once_with("secret.key", mock_key) + + def test_get_or_create_key_race_condition(self) -> None: + """Test that another process's key is used when atomic create fails.""" + our_key = Fernet.generate_key() + their_key = Fernet.generate_key() + + with ( + patch.object(self.token_manager, "_read_secure_file", side_effect=[None, their_key]) as mock_read, + patch.object(self.token_manager, "_atomic_create_secure_file", return_value=False) as mock_atomic_create, + patch("crewai_core.token_manager.Fernet.generate_key", return_value=our_key), + ): + result = self.token_manager._get_or_create_key() + + self.assertEqual(result, their_key) + self.assertEqual(mock_read.call_count, 2) + + @patch("crewai_core.token_manager.TokenManager._atomic_write_secure_file") + def test_save_tokens( + self, mock_write: unittest.mock.MagicMock + ) -> None: + """Test saving tokens encrypts and writes atomically.""" + access_token = "test_token" + expires_at = int((datetime.now() + timedelta(seconds=3600)).timestamp()) + + self.token_manager.save_tokens(access_token, expires_at) + + mock_write.assert_called_once() + args = mock_write.call_args[0] + self.assertEqual(args[0], "tokens.enc") + decrypted_data = self.token_manager.fernet.decrypt(args[1]) + data = json.loads(decrypted_data) + self.assertEqual(data["access_token"], access_token) + expiration = datetime.fromisoformat(data["expiration"]) + self.assertEqual(expiration, datetime.fromtimestamp(expires_at)) + + @patch("crewai_core.token_manager.TokenManager._read_secure_file") + def test_get_token_valid( + self, mock_read: unittest.mock.MagicMock + ) -> None: + """Test getting a valid non-expired token.""" + access_token = "test_token" + expiration = (datetime.now() + timedelta(hours=1)).isoformat() + data = {"access_token": access_token, "expiration": expiration} + encrypted_data = self.token_manager.fernet.encrypt(json.dumps(data).encode()) + mock_read.return_value = encrypted_data + + result = self.token_manager.get_token() + + self.assertEqual(result, access_token) + + @patch("crewai_core.token_manager.TokenManager._read_secure_file") + def test_get_token_expired( + self, mock_read: unittest.mock.MagicMock + ) -> None: + """Test that expired token returns None.""" + access_token = "test_token" + expiration = (datetime.now() - timedelta(hours=1)).isoformat() + data = {"access_token": access_token, "expiration": expiration} + encrypted_data = self.token_manager.fernet.encrypt(json.dumps(data).encode()) + mock_read.return_value = encrypted_data + + result = self.token_manager.get_token() + + self.assertIsNone(result) + + @patch("crewai_core.token_manager.TokenManager._read_secure_file") + def test_get_token_not_found( + self, mock_read: unittest.mock.MagicMock + ) -> None: + """Test that missing token file returns None.""" + mock_read.return_value = None + + result = self.token_manager.get_token() + + self.assertIsNone(result) + + @patch("crewai_core.token_manager.TokenManager._delete_secure_file") + def test_clear_tokens( + self, mock_delete: unittest.mock.MagicMock + ) -> None: + """Test clearing tokens deletes the token file.""" + self.token_manager.clear_tokens() + + mock_delete.assert_called_once_with("tokens.enc") + + +class TestAtomicFileOperations(unittest.TestCase): + """Test atomic file operations directly.""" + + def setUp(self) -> None: + """Set up test fixtures with temp directory.""" + self.temp_dir = tempfile.mkdtemp() + self.original_get_path = TokenManager._get_secure_storage_path + + # Patch to use temp directory + def mock_get_path() -> Path: + return Path(self.temp_dir) + + TokenManager._get_secure_storage_path = staticmethod(mock_get_path) + + def tearDown(self) -> None: + """Clean up temp directory.""" + TokenManager._get_secure_storage_path = staticmethod(self.original_get_path) + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_atomic_create_new_file( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test atomic create succeeds for new file.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + result = tm._atomic_create_secure_file("test.txt", b"content") + + self.assertTrue(result) + file_path = Path(self.temp_dir) / "test.txt" + self.assertTrue(file_path.exists()) + self.assertEqual(file_path.read_bytes(), b"content") + self.assertEqual(file_path.stat().st_mode & 0o777, 0o600) + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_atomic_create_existing_file( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test atomic create fails for existing file.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + # Create file first + file_path = Path(self.temp_dir) / "test.txt" + file_path.write_bytes(b"original") + + result = tm._atomic_create_secure_file("test.txt", b"new content") + + self.assertFalse(result) + self.assertEqual(file_path.read_bytes(), b"original") + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_atomic_write_new_file( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test atomic write creates new file.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + tm._atomic_write_secure_file("test.txt", b"content") + + file_path = Path(self.temp_dir) / "test.txt" + self.assertTrue(file_path.exists()) + self.assertEqual(file_path.read_bytes(), b"content") + self.assertEqual(file_path.stat().st_mode & 0o777, 0o600) + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_atomic_write_overwrites( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test atomic write overwrites existing file.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + file_path = Path(self.temp_dir) / "test.txt" + file_path.write_bytes(b"original") + + tm._atomic_write_secure_file("test.txt", b"new content") + + self.assertEqual(file_path.read_bytes(), b"new content") + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_atomic_write_no_temp_file_on_success( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test that temp file is cleaned up after successful write.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + tm._atomic_write_secure_file("test.txt", b"content") + + # Check no temp files remain + temp_files = list(Path(self.temp_dir).glob(".test.txt.*")) + self.assertEqual(len(temp_files), 0) + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_read_secure_file_exists( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test reading existing file.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + file_path = Path(self.temp_dir) / "test.txt" + file_path.write_bytes(b"content") + + result = tm._read_secure_file("test.txt") + + self.assertEqual(result, b"content") + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_read_secure_file_not_exists( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test reading non-existent file returns None.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + result = tm._read_secure_file("nonexistent.txt") + + self.assertIsNone(result) + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_delete_secure_file_exists( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test deleting existing file.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + file_path = Path(self.temp_dir) / "test.txt" + file_path.write_bytes(b"content") + + tm._delete_secure_file("test.txt") + + self.assertFalse(file_path.exists()) + + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") + def test_delete_secure_file_not_exists( + self, mock_get_key: unittest.mock.MagicMock + ) -> None: + """Test deleting non-existent file doesn't raise.""" + mock_get_key.return_value = Fernet.generate_key() + tm = TokenManager() + + # Should not raise + tm._delete_secure_file("nonexistent.txt") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/lib/crewai/tests/cli/test_train_crew.py b/lib/cli/tests/test_train_crew.py similarity index 87% rename from lib/crewai/tests/cli/test_train_crew.py rename to lib/cli/tests/test_train_crew.py index f1694472f..47263032e 100644 --- a/lib/crewai/tests/cli/test_train_crew.py +++ b/lib/cli/tests/test_train_crew.py @@ -1,10 +1,10 @@ import subprocess from unittest import mock -from crewai.cli.train_crew import train_crew +from crewai_cli.train_crew import train_crew -@mock.patch("crewai.cli.train_crew.subprocess.run") +@mock.patch("crewai_cli.train_crew.subprocess.run") def test_train_crew_positive_iterations(mock_subprocess_run): n_iterations = 5 mock_subprocess_run.return_value = subprocess.CompletedProcess( @@ -24,7 +24,7 @@ def test_train_crew_positive_iterations(mock_subprocess_run): ) -@mock.patch("crewai.cli.train_crew.click") +@mock.patch("crewai_cli.train_crew.click") def test_train_crew_zero_iterations(click): train_crew(0, "trained_agents_data.pkl") click.echo.assert_called_once_with( @@ -33,7 +33,7 @@ def test_train_crew_zero_iterations(click): ) -@mock.patch("crewai.cli.train_crew.click") +@mock.patch("crewai_cli.train_crew.click") def test_train_crew_negative_iterations(click): train_crew(-2, "trained_agents_data.pkl") click.echo.assert_called_once_with( @@ -42,8 +42,8 @@ def test_train_crew_negative_iterations(click): ) -@mock.patch("crewai.cli.train_crew.click") -@mock.patch("crewai.cli.train_crew.subprocess.run") +@mock.patch("crewai_cli.train_crew.click") +@mock.patch("crewai_cli.train_crew.subprocess.run") def test_train_crew_called_process_error(mock_subprocess_run, click): n_iterations = 5 mock_subprocess_run.side_effect = subprocess.CalledProcessError( @@ -71,8 +71,8 @@ def test_train_crew_called_process_error(mock_subprocess_run, click): ) -@mock.patch("crewai.cli.train_crew.click") -@mock.patch("crewai.cli.train_crew.subprocess.run") +@mock.patch("crewai_cli.train_crew.click") +@mock.patch("crewai_cli.train_crew.subprocess.run") def test_train_crew_unexpected_exception(mock_subprocess_run, click): n_iterations = 5 mock_subprocess_run.side_effect = Exception("Unexpected error") diff --git a/lib/cli/tests/test_utils.py b/lib/cli/tests/test_utils.py new file mode 100644 index 000000000..0e5695054 --- /dev/null +++ b/lib/cli/tests/test_utils.py @@ -0,0 +1,107 @@ +import os +import shutil +import tempfile +from pathlib import Path + +import pytest +from crewai_cli import utils + + +@pytest.fixture +def temp_tree(): + root_dir = tempfile.mkdtemp() + + create_file(os.path.join(root_dir, "file1.txt"), "Hello, world!") + create_file(os.path.join(root_dir, "file2.txt"), "Another file") + os.mkdir(os.path.join(root_dir, "empty_dir")) + nested_dir = os.path.join(root_dir, "nested_dir") + os.mkdir(nested_dir) + create_file(os.path.join(nested_dir, "nested_file.txt"), "Nested content") + + yield root_dir + + shutil.rmtree(root_dir) + + +def create_file(path, content): + with open(path, "w") as f: + f.write(content) + + +def test_tree_find_and_replace_file_content(temp_tree): + utils.tree_find_and_replace(temp_tree, "world", "universe") + with open(os.path.join(temp_tree, "file1.txt"), "r") as f: + assert f.read() == "Hello, universe!" + + +def test_tree_find_and_replace_file_name(temp_tree): + old_path = os.path.join(temp_tree, "file2.txt") + new_path = os.path.join(temp_tree, "file2_renamed.txt") + os.rename(old_path, new_path) + utils.tree_find_and_replace(temp_tree, "renamed", "modified") + assert os.path.exists(os.path.join(temp_tree, "file2_modified.txt")) + assert not os.path.exists(new_path) + + +def test_tree_find_and_replace_directory_name(temp_tree): + utils.tree_find_and_replace(temp_tree, "empty", "renamed") + assert os.path.exists(os.path.join(temp_tree, "renamed_dir")) + assert not os.path.exists(os.path.join(temp_tree, "empty_dir")) + + +def test_tree_find_and_replace_nested_content(temp_tree): + utils.tree_find_and_replace(temp_tree, "Nested", "Updated") + with open(os.path.join(temp_tree, "nested_dir", "nested_file.txt"), "r") as f: + assert f.read() == "Updated content" + + +def test_tree_find_and_replace_no_matches(temp_tree): + utils.tree_find_and_replace(temp_tree, "nonexistent", "replacement") + assert set(os.listdir(temp_tree)) == { + "file1.txt", + "file2.txt", + "empty_dir", + "nested_dir", + } + + +def test_tree_copy_full_structure(temp_tree): + dest_dir = tempfile.mkdtemp() + try: + utils.tree_copy(temp_tree, dest_dir) + assert set(os.listdir(dest_dir)) == set(os.listdir(temp_tree)) + assert os.path.isfile(os.path.join(dest_dir, "file1.txt")) + assert os.path.isfile(os.path.join(dest_dir, "file2.txt")) + assert os.path.isdir(os.path.join(dest_dir, "empty_dir")) + assert os.path.isdir(os.path.join(dest_dir, "nested_dir")) + assert os.path.isfile(os.path.join(dest_dir, "nested_dir", "nested_file.txt")) + finally: + shutil.rmtree(dest_dir) + + +def test_tree_copy_preserve_content(temp_tree): + dest_dir = tempfile.mkdtemp() + try: + utils.tree_copy(temp_tree, dest_dir) + with open(os.path.join(dest_dir, "file1.txt"), "r") as f: + assert f.read() == "Hello, world!" + with open(os.path.join(dest_dir, "nested_dir", "nested_file.txt"), "r") as f: + assert f.read() == "Nested content" + finally: + shutil.rmtree(dest_dir) + + +def test_tree_copy_to_existing_directory(temp_tree): + dest_dir = tempfile.mkdtemp() + try: + create_file(os.path.join(dest_dir, "existing_file.txt"), "I was here first") + utils.tree_copy(temp_tree, dest_dir) + assert os.path.isfile(os.path.join(dest_dir, "existing_file.txt")) + assert os.path.isfile(os.path.join(dest_dir, "file1.txt")) + finally: + shutil.rmtree(dest_dir) + + +# Tests for extract_available_exports, get_crews, get_flows, fetch_crews, +# is_valid_tool live in lib/crewai/tests/cli/test_utils.py — the canonical +# implementations are in crewai.utilities.project_utils. diff --git a/lib/cli/tests/test_version.py b/lib/cli/tests/test_version.py new file mode 100644 index 000000000..2d6d38eee --- /dev/null +++ b/lib/cli/tests/test_version.py @@ -0,0 +1,374 @@ +"""Test for version management.""" + +import json +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +from crewai_cli.version import get_crewai_version as _get_ver +from crewai_cli.version import ( + get_crewai_version, + get_latest_version_from_pypi, + is_current_version_yanked, + is_newer_version_available, +) +from crewai_core.version import ( + _find_latest_non_yanked_version, + _get_cache_file, + _is_cache_valid, + _is_version_yanked, +) + + +def test_dynamic_versioning_consistency() -> None: + """Test that dynamic versioning provides consistent version across all access methods.""" + cli_version = get_crewai_version() + package_version = _get_ver() + + assert cli_version == package_version + + assert package_version is not None + assert len(package_version.strip()) > 0 + + +class TestVersionChecking: + """Test version checking utilities.""" + + def test_get_crewai_version(self) -> None: + """Test getting current crewai version.""" + version = get_crewai_version() + assert isinstance(version, str) + assert len(version) > 0 + + def test_get_cache_file(self) -> None: + """Test cache file path generation.""" + cache_file = _get_cache_file() + assert isinstance(cache_file, Path) + assert cache_file.name == "version_cache.json" + + def test_is_cache_valid_with_fresh_cache(self) -> None: + """Test cache validation with fresh cache.""" + cache_data = {"timestamp": datetime.now().isoformat(), "version": "1.0.0"} + assert _is_cache_valid(cache_data) is True + + def test_is_cache_valid_with_stale_cache(self) -> None: + """Test cache validation with stale cache.""" + old_time = datetime.now() - timedelta(hours=25) + cache_data = {"timestamp": old_time.isoformat(), "version": "1.0.0"} + assert _is_cache_valid(cache_data) is False + + def test_is_cache_valid_with_missing_timestamp(self) -> None: + """Test cache validation with missing timestamp.""" + cache_data = {"version": "1.0.0"} + assert _is_cache_valid(cache_data) is False + + @patch("crewai_core.version.Path.exists") + @patch("crewai_core.version.request.urlopen") + def test_get_latest_version_from_pypi_success( + self, mock_urlopen: MagicMock, mock_exists: MagicMock + ) -> None: + """Test successful PyPI version fetch uses releases data.""" + mock_exists.return_value = False + + releases = { + "1.0.0": [{"yanked": False}], + "2.0.0": [{"yanked": False}], + "2.1.0": [{"yanked": True, "yanked_reason": "bad release"}], + } + mock_response = MagicMock() + mock_response.read.return_value = json.dumps( + {"info": {"version": "2.1.0"}, "releases": releases} + ).encode() + mock_urlopen.return_value.__enter__.return_value = mock_response + + version = get_latest_version_from_pypi() + assert version == "2.0.0" + + @patch("crewai_core.version.Path.exists") + @patch("crewai_core.version.request.urlopen") + def test_get_latest_version_from_pypi_failure( + self, mock_urlopen: MagicMock, mock_exists: MagicMock + ) -> None: + """Test PyPI version fetch failure.""" + from urllib.error import URLError + + mock_exists.return_value = False + + mock_urlopen.side_effect = URLError("Network error") + + version = get_latest_version_from_pypi() + assert version is None + + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") + def test_is_newer_version_available_true( + self, mock_latest: MagicMock, mock_current: MagicMock + ) -> None: + """Test when newer version is available.""" + mock_current.return_value = "1.0.0" + mock_latest.return_value = "2.0.0" + + is_newer, current, latest = is_newer_version_available() + assert is_newer is True + assert current == "1.0.0" + assert latest == "2.0.0" + + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") + def test_is_newer_version_available_false( + self, mock_latest: MagicMock, mock_current: MagicMock + ) -> None: + """Test when no newer version is available.""" + mock_current.return_value = "2.0.0" + mock_latest.return_value = "2.0.0" + + is_newer, current, latest = is_newer_version_available() + assert is_newer is False + assert current == "2.0.0" + assert latest == "2.0.0" + + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") + def test_is_newer_version_available_with_none_latest( + self, mock_latest: MagicMock, mock_current: MagicMock + ) -> None: + """Test when PyPI fetch fails.""" + mock_current.return_value = "1.0.0" + mock_latest.return_value = None + + is_newer, current, latest = is_newer_version_available() + assert is_newer is False + assert current == "1.0.0" + assert latest is None + + +class TestFindLatestNonYankedVersion: + """Test _find_latest_non_yanked_version helper.""" + + def test_skips_yanked_versions(self) -> None: + """Test that yanked versions are skipped.""" + releases = { + "1.0.0": [{"yanked": False}], + "2.0.0": [{"yanked": True}], + } + assert _find_latest_non_yanked_version(releases) == "1.0.0" + + def test_returns_highest_non_yanked(self) -> None: + """Test that the highest non-yanked version is returned.""" + releases = { + "1.0.0": [{"yanked": False}], + "1.5.0": [{"yanked": False}], + "2.0.0": [{"yanked": True}], + } + assert _find_latest_non_yanked_version(releases) == "1.5.0" + + def test_returns_none_when_all_yanked(self) -> None: + """Test that None is returned when all versions are yanked.""" + releases = { + "1.0.0": [{"yanked": True}], + "2.0.0": [{"yanked": True}], + } + assert _find_latest_non_yanked_version(releases) is None + + def test_skips_prerelease_versions(self) -> None: + """Test that pre-release versions are skipped.""" + releases = { + "1.0.0": [{"yanked": False}], + "2.0.0a1": [{"yanked": False}], + "2.0.0rc1": [{"yanked": False}], + } + assert _find_latest_non_yanked_version(releases) == "1.0.0" + + def test_skips_versions_with_empty_files(self) -> None: + """Test that versions with no files are skipped.""" + releases: dict[str, list[dict[str, bool]]] = { + "1.0.0": [{"yanked": False}], + "2.0.0": [], + } + assert _find_latest_non_yanked_version(releases) == "1.0.0" + + def test_handles_invalid_version_strings(self) -> None: + """Test that invalid version strings are skipped.""" + releases = { + "1.0.0": [{"yanked": False}], + "not-a-version": [{"yanked": False}], + } + assert _find_latest_non_yanked_version(releases) == "1.0.0" + + def test_partially_yanked_files_not_considered_yanked(self) -> None: + """Test that a version with some non-yanked files is not yanked.""" + releases = { + "1.0.0": [{"yanked": False}], + "2.0.0": [{"yanked": True}, {"yanked": False}], + } + assert _find_latest_non_yanked_version(releases) == "2.0.0" + + +class TestIsVersionYanked: + """Test _is_version_yanked helper.""" + + def test_non_yanked_version(self) -> None: + """Test a non-yanked version returns False.""" + releases = {"1.0.0": [{"yanked": False}]} + is_yanked, reason = _is_version_yanked("1.0.0", releases) + assert is_yanked is False + assert reason == "" + + def test_yanked_version_with_reason(self) -> None: + """Test a yanked version returns True with reason.""" + releases = { + "1.0.0": [{"yanked": True, "yanked_reason": "critical bug"}], + } + is_yanked, reason = _is_version_yanked("1.0.0", releases) + assert is_yanked is True + assert reason == "critical bug" + + def test_yanked_version_without_reason(self) -> None: + """Test a yanked version returns True with empty reason.""" + releases = {"1.0.0": [{"yanked": True}]} + is_yanked, reason = _is_version_yanked("1.0.0", releases) + assert is_yanked is True + assert reason == "" + + def test_unknown_version(self) -> None: + """Test an unknown version returns False.""" + releases = {"1.0.0": [{"yanked": False}]} + is_yanked, reason = _is_version_yanked("9.9.9", releases) + assert is_yanked is False + assert reason == "" + + def test_partially_yanked_files(self) -> None: + """Test a version with mixed yanked/non-yanked files is not yanked.""" + releases = { + "1.0.0": [{"yanked": True}, {"yanked": False}], + } + is_yanked, reason = _is_version_yanked("1.0.0", releases) + assert is_yanked is False + assert reason == "" + + def test_multiple_yanked_files_picks_first_reason(self) -> None: + """Test that the first available reason is returned.""" + releases = { + "1.0.0": [ + {"yanked": True, "yanked_reason": ""}, + {"yanked": True, "yanked_reason": "second reason"}, + ], + } + is_yanked, reason = _is_version_yanked("1.0.0", releases) + assert is_yanked is True + assert reason == "second reason" + + +class TestIsCurrentVersionYanked: + """Test is_current_version_yanked public function.""" + + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") + def test_reads_from_valid_cache( + self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path + ) -> None: + """Test reading yanked status from a valid cache.""" + mock_version.return_value = "1.0.0" + cache_file = tmp_path / "version_cache.json" + cache_data = { + "version": "2.0.0", + "timestamp": datetime.now().isoformat(), + "current_version": "1.0.0", + "current_version_yanked": True, + "current_version_yanked_reason": "bad release", + } + cache_file.write_text(json.dumps(cache_data)) + mock_cache_file.return_value = cache_file + + is_yanked, reason = is_current_version_yanked() + assert is_yanked is True + assert reason == "bad release" + + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") + def test_not_yanked_from_cache( + self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path + ) -> None: + """Test non-yanked status from a valid cache.""" + mock_version.return_value = "2.0.0" + cache_file = tmp_path / "version_cache.json" + cache_data = { + "version": "2.0.0", + "timestamp": datetime.now().isoformat(), + "current_version": "2.0.0", + "current_version_yanked": False, + "current_version_yanked_reason": "", + } + cache_file.write_text(json.dumps(cache_data)) + mock_cache_file.return_value = cache_file + + is_yanked, reason = is_current_version_yanked() + assert is_yanked is False + assert reason == "" + + @patch("crewai_core.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") + def test_triggers_fetch_on_stale_cache( + self, + mock_cache_file: MagicMock, + mock_version: MagicMock, + mock_fetch: MagicMock, + tmp_path: Path, + ) -> None: + """Test that a stale cache triggers a re-fetch.""" + mock_version.return_value = "1.0.0" + cache_file = tmp_path / "version_cache.json" + old_time = datetime.now() - timedelta(hours=25) + cache_data = { + "version": "2.0.0", + "timestamp": old_time.isoformat(), + "current_version": "1.0.0", + "current_version_yanked": True, + "current_version_yanked_reason": "old reason", + } + cache_file.write_text(json.dumps(cache_data)) + mock_cache_file.return_value = cache_file + + fresh_cache = { + "version": "2.0.0", + "timestamp": datetime.now().isoformat(), + "current_version": "1.0.0", + "current_version_yanked": False, + "current_version_yanked_reason": "", + } + + def write_fresh_cache() -> str: + cache_file.write_text(json.dumps(fresh_cache)) + return "2.0.0" + + mock_fetch.side_effect = lambda: write_fresh_cache() + + is_yanked, reason = is_current_version_yanked() + assert is_yanked is False + mock_fetch.assert_called_once() + + @patch("crewai_core.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") + def test_returns_false_on_fetch_failure( + self, + mock_cache_file: MagicMock, + mock_version: MagicMock, + mock_fetch: MagicMock, + tmp_path: Path, + ) -> None: + """Test that fetch failure returns not yanked.""" + mock_version.return_value = "1.0.0" + cache_file = tmp_path / "version_cache.json" + mock_cache_file.return_value = cache_file + mock_fetch.return_value = None + + is_yanked, reason = is_current_version_yanked() + assert is_yanked is False + assert reason == "" + + + +# TestConsoleFormatterVersionCheck tests remain in lib/crewai/tests/cli/test_version.py +# as they depend on crewai.events.utils.console_formatter (core package). diff --git a/lib/cli/tests/tools/__init__.py b/lib/cli/tests/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/tests/cli/tools/test_main.py b/lib/cli/tests/tools/test_main.py similarity index 78% rename from lib/crewai/tests/cli/tools/test_main.py rename to lib/cli/tests/tools/test_main.py index ed51db74a..b232dc5f8 100644 --- a/lib/crewai/tests/cli/tools/test_main.py +++ b/lib/cli/tests/tools/test_main.py @@ -9,8 +9,8 @@ from unittest import mock from unittest.mock import MagicMock, patch import pytest -from crewai.cli.shared.token_manager import TokenManager -from crewai.cli.tools.main import ToolCommand +from crewai_cli.shared.token_manager import TokenManager +from crewai_cli.tools.main import ToolCommand from pytest import raises @@ -41,7 +41,7 @@ def tool_command(): yield tool_command -@patch("crewai.cli.tools.main.subprocess.run") +@patch("crewai_cli.tools.main.subprocess.run") def test_create_success(mock_subprocess, capsys, tool_command): with in_temp_dir(): tool_command.create("test-tool") @@ -63,9 +63,9 @@ def test_create_success(mock_subprocess, capsys, tool_command): mock_subprocess.assert_called_once_with(["git", "init"], check=True) -@patch("crewai.cli.tools.main.subprocess.run") -@patch("crewai.cli.plus_api.PlusAPI.get_tool") -@patch("crewai.cli.tools.main.ToolCommand._print_current_organization") +@patch("crewai_cli.tools.main.subprocess.run") +@patch("crewai_cli.plus_api.PlusAPI.get_tool") +@patch("crewai_cli.tools.main.ToolCommand._print_current_organization") def test_install_success( mock_print_org, mock_get, mock_subprocess_run, capsys, tool_command ): @@ -101,8 +101,8 @@ def test_install_success( mock_print_org.assert_called_once() -@patch("crewai.cli.tools.main.subprocess.run") -@patch("crewai.cli.plus_api.PlusAPI.get_tool") +@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): mock_get_response = MagicMock() mock_get_response.status_code = 200 @@ -132,7 +132,7 @@ def test_install_success_from_pypi(mock_get, mock_subprocess_run, capsys, tool_c ) -@patch("crewai.cli.plus_api.PlusAPI.get_tool") +@patch("crewai_cli.plus_api.PlusAPI.get_tool") def test_install_tool_not_found(mock_get, capsys, tool_command): mock_get_response = MagicMock() mock_get_response.status_code = 404 @@ -146,7 +146,7 @@ def test_install_tool_not_found(mock_get, capsys, tool_command): mock_get.assert_called_once_with("non-existent-tool") -@patch("crewai.cli.plus_api.PlusAPI.get_tool") +@patch("crewai_cli.plus_api.PlusAPI.get_tool") def test_install_api_error(mock_get, capsys, tool_command): mock_get_response = MagicMock() mock_get_response.status_code = 500 @@ -160,9 +160,9 @@ def test_install_api_error(mock_get, capsys, tool_command): mock_get.assert_called_once_with("error-tool") -@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False) -@patch("crewai.cli.tools.main.git.Repository.__init__", return_value=None) -def test_publish_when_not_in_sync(mock_init, mock_is_synced, capsys, tool_command): +@patch("crewai_cli.tools.main.git.Repository.fetch") +@patch("crewai_cli.tools.main.git.Repository.is_synced", return_value=False) +def test_publish_when_not_in_sync(mock_is_synced, mock_fetch, capsys, tool_command): with raises(SystemExit): tool_command.publish(is_public=True) @@ -170,33 +170,35 @@ def test_publish_when_not_in_sync(mock_init, mock_is_synced, capsys, tool_comman assert "Local changes need to be resolved before publishing" in output -@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") -@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") -@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") -@patch("crewai.cli.tools.main.subprocess.run") -@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch("crewai_cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai_cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai_cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai_cli.tools.main.subprocess.run") +@patch("crewai_cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) @patch( - "crewai.cli.tools.main.open", + "crewai_cli.tools.main.open", new_callable=unittest.mock.mock_open, read_data=b"sample tarball content", ) -@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.fetch") +@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", + "crewai.utilities.project_utils.extract_available_exports", return_value=[{"name": "SampleTool"}], ) @patch( - "crewai.cli.tools.main.extract_tools_metadata", + "crewai.utilities.project_utils.extract_tools_metadata", return_value=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], ) -@patch("crewai.cli.tools.main.ToolCommand._print_current_organization") +@patch("crewai_cli.tools.main.ToolCommand._print_current_organization") def test_publish_when_not_in_sync_and_force( mock_print_org, mock_tools_metadata, mock_available_exports, mock_is_synced, mock_publish, + mock_fetch, mock_open, mock_listdir, mock_subprocess_run, @@ -234,24 +236,25 @@ def test_publish_when_not_in_sync_and_force( 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_version", return_value="1.0.0") -@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") -@patch("crewai.cli.tools.main.subprocess.run") -@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch("crewai_cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai_cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai_cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai_cli.tools.main.subprocess.run") +@patch("crewai_cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) @patch( - "crewai.cli.tools.main.open", + "crewai_cli.tools.main.open", new_callable=unittest.mock.mock_open, read_data=b"sample tarball content", ) -@patch("crewai.cli.plus_api.PlusAPI.publish_tool") -@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True) +@patch("crewai_cli.tools.main.git.Repository.fetch") +@patch("crewai_cli.plus_api.PlusAPI.publish_tool") +@patch("crewai_cli.tools.main.git.Repository.is_synced", return_value=True) @patch( - "crewai.cli.tools.main.extract_available_exports", + "crewai.utilities.project_utils.extract_available_exports", return_value=[{"name": "SampleTool"}], ) @patch( - "crewai.cli.tools.main.extract_tools_metadata", + "crewai.utilities.project_utils.extract_tools_metadata", return_value=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], ) def test_publish_success( @@ -259,6 +262,7 @@ def test_publish_success( mock_available_exports, mock_is_synced, mock_publish, + mock_fetch, mock_open, mock_listdir, mock_subprocess_run, @@ -295,23 +299,23 @@ def test_publish_success( ) -@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") -@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") -@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") -@patch("crewai.cli.tools.main.subprocess.run") -@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch("crewai_cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai_cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai_cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai_cli.tools.main.subprocess.run") +@patch("crewai_cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) @patch( - "crewai.cli.tools.main.open", + "crewai_cli.tools.main.open", new_callable=unittest.mock.mock_open, read_data=b"sample tarball content", ) -@patch("crewai.cli.plus_api.PlusAPI.publish_tool") +@patch("crewai_cli.plus_api.PlusAPI.publish_tool") @patch( - "crewai.cli.tools.main.extract_available_exports", + "crewai.utilities.project_utils.extract_available_exports", return_value=[{"name": "SampleTool"}], ) @patch( - "crewai.cli.tools.main.extract_tools_metadata", + "crewai.utilities.project_utils.extract_tools_metadata", return_value=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], ) def test_publish_failure( @@ -341,23 +345,23 @@ def test_publish_failure( mock_publish.assert_called_once() -@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") -@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") -@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") -@patch("crewai.cli.tools.main.subprocess.run") -@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch("crewai_cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai_cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai_cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai_cli.tools.main.subprocess.run") +@patch("crewai_cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) @patch( - "crewai.cli.tools.main.open", + "crewai_cli.tools.main.open", new_callable=unittest.mock.mock_open, read_data=b"sample tarball content", ) -@patch("crewai.cli.plus_api.PlusAPI.publish_tool") +@patch("crewai_cli.plus_api.PlusAPI.publish_tool") @patch( - "crewai.cli.tools.main.extract_available_exports", + "crewai.utilities.project_utils.extract_available_exports", return_value=[{"name": "SampleTool"}], ) @patch( - "crewai.cli.tools.main.extract_tools_metadata", + "crewai.utilities.project_utils.extract_tools_metadata", return_value=[{"name": "SampleTool", "humanized_name": "sample_tool", "description": "A sample tool", "run_params_schema": {}, "init_params_schema": {}, "env_vars": []}], ) def test_publish_api_error( @@ -387,24 +391,24 @@ def test_publish_api_error( mock_publish.assert_called_once() -@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") -@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") -@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") -@patch("crewai.cli.tools.main.subprocess.run") -@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch("crewai_cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai_cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai_cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai_cli.tools.main.subprocess.run") +@patch("crewai_cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) @patch( - "crewai.cli.tools.main.open", + "crewai_cli.tools.main.open", new_callable=unittest.mock.mock_open, read_data=b"sample tarball content", ) -@patch("crewai.cli.plus_api.PlusAPI.publish_tool") -@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True) +@patch("crewai_cli.plus_api.PlusAPI.publish_tool") +@patch("crewai_cli.tools.main.git.Repository.is_synced", return_value=True) @patch( - "crewai.cli.tools.main.extract_available_exports", + "crewai.utilities.project_utils.extract_available_exports", return_value=[{"name": "SampleTool"}], ) @patch( - "crewai.cli.tools.main.extract_tools_metadata", + "crewai.utilities.project_utils.extract_tools_metadata", side_effect=Exception("Failed to extract metadata"), ) def test_publish_metadata_extraction_failure_continues_with_warning( @@ -444,7 +448,7 @@ def test_publish_metadata_extraction_failure_continues_with_warning( ) -@patch("crewai.cli.tools.main.Settings") +@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" @@ -455,7 +459,7 @@ def test_print_current_organization_with_org(mock_settings, capsys, tool_command assert "Current organization: Test Organization (test-org-uuid)" in output -@patch("crewai.cli.tools.main.Settings") +@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 diff --git a/lib/crewai/tests/cli/triggers/test_main.py b/lib/cli/tests/triggers/test_main.py similarity index 91% rename from lib/crewai/tests/cli/triggers/test_main.py rename to lib/cli/tests/triggers/test_main.py index 641abc7cf..dc754c003 100644 --- a/lib/crewai/tests/cli/triggers/test_main.py +++ b/lib/cli/tests/triggers/test_main.py @@ -4,12 +4,12 @@ import unittest from unittest.mock import Mock, patch import httpx -from crewai.cli.triggers.main import TriggersCommand +from crewai_cli.triggers.main import TriggersCommand class TestTriggersCommand(unittest.TestCase): - @patch("crewai.cli.command.get_auth_token") - @patch("crewai.cli.command.PlusAPI") + @patch("crewai_cli.command.get_auth_token") + @patch("crewai_cli.command.PlusAPI") def setUp(self, mock_plus_api, mock_get_auth_token): self.mock_get_auth_token = mock_get_auth_token self.mock_plus_api = mock_plus_api @@ -19,7 +19,7 @@ class TestTriggersCommand(unittest.TestCase): self.triggers_command = TriggersCommand() self.mock_client = self.triggers_command.plus_api_client - @patch("crewai.cli.triggers.main.console.print") + @patch("crewai_cli.triggers.main.console.print") def test_list_triggers_success(self, mock_console_print): mock_response = Mock(spec=httpx.Response) mock_response.status_code = 200 @@ -48,7 +48,7 @@ class TestTriggersCommand(unittest.TestCase): self.mock_client.get_triggers.assert_called_once() mock_console_print.assert_any_call("[bold blue]Fetching available triggers...[/bold blue]") - @patch("crewai.cli.triggers.main.console.print") + @patch("crewai_cli.triggers.main.console.print") def test_list_triggers_no_apps(self, mock_console_print): mock_response = Mock(spec=httpx.Response) mock_response.status_code = 200 @@ -60,7 +60,7 @@ class TestTriggersCommand(unittest.TestCase): mock_console_print.assert_any_call("[yellow]No triggers found.[/yellow]") - @patch("crewai.cli.triggers.main.console.print") + @patch("crewai_cli.triggers.main.console.print") def test_list_triggers_api_error(self, mock_console_print): self.mock_client.get_triggers.side_effect = Exception("API Error") @@ -69,7 +69,7 @@ class TestTriggersCommand(unittest.TestCase): mock_console_print.assert_any_call("[bold red]Error fetching triggers: API Error[/bold red]") - @patch("crewai.cli.triggers.main.console.print") + @patch("crewai_cli.triggers.main.console.print") def test_execute_with_trigger_invalid_format(self, mock_console_print): with self.assertRaises(SystemExit): self.triggers_command.execute_with_trigger("invalid-format") @@ -78,7 +78,7 @@ class TestTriggersCommand(unittest.TestCase): "[bold red]Error: Trigger must be in format 'app_slug/trigger_slug'[/bold red]" ) - @patch("crewai.cli.triggers.main.console.print") + @patch("crewai_cli.triggers.main.console.print") @patch.object(TriggersCommand, "_run_crew_with_payload") def test_execute_with_trigger_success(self, mock_run_crew, mock_console_print): mock_response = Mock(spec=httpx.Response) @@ -97,7 +97,7 @@ class TestTriggersCommand(unittest.TestCase): "[bold blue]Fetching trigger payload for test-app/test-trigger...[/bold blue]" ) - @patch("crewai.cli.triggers.main.console.print") + @patch("crewai_cli.triggers.main.console.print") def test_execute_with_trigger_not_found(self, mock_console_print): mock_response = Mock(spec=httpx.Response) mock_response.status_code = 404 @@ -109,7 +109,7 @@ class TestTriggersCommand(unittest.TestCase): mock_console_print.assert_any_call("[bold red]Error: Trigger not found[/bold red]") - @patch("crewai.cli.triggers.main.console.print") + @patch("crewai_cli.triggers.main.console.print") def test_execute_with_trigger_api_error(self, mock_console_print): self.mock_client.get_trigger_payload.side_effect = Exception("API Error") @@ -157,7 +157,7 @@ class TestTriggersCommand(unittest.TestCase): check=True ) - @patch("crewai.cli.triggers.main.console.print") + @patch("crewai_cli.triggers.main.console.print") def test_execute_with_trigger_with_default_error_message(self, mock_console_print): mock_response = Mock(spec=httpx.Response) mock_response.status_code = 404 diff --git a/lib/crewai-core/README.md b/lib/crewai-core/README.md new file mode 100644 index 000000000..3cf166d7a --- /dev/null +++ b/lib/crewai-core/README.md @@ -0,0 +1,8 @@ +# crewai-core + +Shared utilities used by both `crewai` and `crewai-cli`: version lookup, storage +paths, user-data helpers, telemetry, and the printer. + +This package is a leaf — it has no dependency on the `crewai` framework — and is +pulled in transitively by `crewai` and `crewai-cli`. End users do not normally +install it directly. diff --git a/lib/crewai-core/pyproject.toml b/lib/crewai-core/pyproject.toml new file mode 100644 index 000000000..92447b057 --- /dev/null +++ b/lib/crewai-core/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "crewai-core" +dynamic = ["version"] +description = "Shared utilities for CrewAI — version, paths, user-data, telemetry, printer." +readme = "README.md" +authors = [ + { name = "Greyson R. LaLonde", email = "greyson@crewai.com" } +] +requires-python = ">=3.10, <3.14" +dependencies = [ + "appdirs~=1.4.4", + "cryptography>=42.0", + "httpx~=0.28.1", + "packaging>=23.0", + "portalocker~=2.7.0", + "pyjwt>=2.9.0,<3", + "pydantic>=2.11.9,<2.13", + "rich>=13.7.1", + "opentelemetry-api~=1.34.0", + "opentelemetry-sdk~=1.34.0", + "opentelemetry-exporter-otlp-proto-http~=1.34.0", + "tomli~=2.0.2", +] + +[project.urls] +Homepage = "https://crewai.com" +Documentation = "https://docs.crewai.com" +Repository = "https://github.com/crewAIInc/crewAI" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/crewai_core/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/crewai_core"] diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py new file mode 100644 index 000000000..576c3fdf8 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/__init__.py @@ -0,0 +1 @@ +__version__ = "1.14.5a2" diff --git a/lib/crewai-core/src/crewai_core/auth/__init__.py b/lib/crewai-core/src/crewai_core/auth/__init__.py new file mode 100644 index 000000000..fd0f1c102 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/__init__.py @@ -0,0 +1,24 @@ +"""OAuth2 authentication primitives — shared by crewai and crewai-cli.""" + +from __future__ import annotations + +from crewai_core.auth.oauth2 import ( + AuthenticationCommand as AuthenticationCommand, + Oauth2Settings as Oauth2Settings, + ProviderFactory as ProviderFactory, +) +from crewai_core.auth.token import ( + AuthError as AuthError, + get_auth_token as get_auth_token, +) +from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token + + +__all__ = [ + "AuthError", + "AuthenticationCommand", + "Oauth2Settings", + "ProviderFactory", + "get_auth_token", + "validate_jwt_token", +] diff --git a/lib/crewai-core/src/crewai_core/auth/constants.py b/lib/crewai-core/src/crewai_core/auth/constants.py new file mode 100644 index 000000000..e8daef120 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/constants.py @@ -0,0 +1,8 @@ +"""Authentication constants.""" + +from __future__ import annotations + +from typing import Final + + +ALGORITHMS: Final[list[str]] = ["RS256"] diff --git a/lib/crewai/src/crewai/cli/authentication/main.py b/lib/crewai-core/src/crewai_core/auth/oauth2.py similarity index 71% rename from lib/crewai/src/crewai/cli/authentication/main.py rename to lib/crewai-core/src/crewai_core/auth/oauth2.py index 7bbda61d5..744a483b4 100644 --- a/lib/crewai/src/crewai/cli/authentication/main.py +++ b/lib/crewai-core/src/crewai_core/auth/oauth2.py @@ -1,3 +1,7 @@ +"""OAuth2 device-flow authentication for the CrewAI platform.""" + +from __future__ import annotations + import time from typing import TYPE_CHECKING, Any, TypeVar, cast import webbrowser @@ -6,9 +10,9 @@ import httpx from pydantic import BaseModel, Field from rich.console import Console -from crewai.cli.authentication.utils import validate_jwt_token -from crewai.cli.config import Settings -from crewai.cli.shared.token_manager import TokenManager +from crewai_core.auth.utils import validate_jwt_token +from crewai_core.settings import Settings +from crewai_core.token_manager import TokenManager console = Console() @@ -17,6 +21,8 @@ TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings") class Oauth2Settings(BaseModel): + """OAuth2 provider configuration.""" + provider: str = Field( description="OAuth2 provider used for authentication (e.g., workos, okta, auth0)." ) @@ -37,8 +43,7 @@ class Oauth2Settings(BaseModel): @classmethod def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings: - """Create an Oauth2Settings instance from the CLI settings.""" - + """Build an ``Oauth2Settings`` instance from the persisted CrewAI settings.""" settings = Settings() return cls( @@ -51,23 +56,25 @@ class Oauth2Settings(BaseModel): if TYPE_CHECKING: - from crewai.cli.authentication.providers.base_provider import BaseProvider + from crewai_core.auth.providers.base_provider import BaseProvider class ProviderFactory: + """Factory for resolving the configured OAuth2 provider.""" + @classmethod def from_settings( cls: type["ProviderFactory"], # noqa: UP037 settings: Oauth2Settings | None = None, ) -> "BaseProvider": # noqa: UP037 + """Create a provider instance from settings, importing the module dynamically.""" settings = settings or Oauth2Settings.from_settings() import importlib module = importlib.import_module( - f"crewai.cli.authentication.providers.{settings.provider.lower()}" + f"crewai_core.auth.providers.{settings.provider.lower()}" ) - # Converts from snake_case to CamelCase to obtain the provider class name. provider = getattr( module, f"{''.join(word.capitalize() for word in settings.provider.split('_'))}Provider", @@ -77,12 +84,14 @@ class ProviderFactory: class AuthenticationCommand: + """Drives the OAuth2 device-flow login against the configured provider.""" + def __init__(self) -> None: self.token_manager = TokenManager() self.oauth2_provider = ProviderFactory.from_settings() def login(self) -> None: - """Sign up to CrewAI+""" + """Sign in to the CrewAI platform via the OAuth2 device flow.""" console.print("Signing in to CrewAI AMP...\n", style="bold blue") device_code_data = self._get_device_code() @@ -91,8 +100,7 @@ class AuthenticationCommand: return self._poll_for_token(device_code_data) def _get_device_code(self) -> dict[str, Any]: - """Get the device code to authenticate the user.""" - + """Request a device code from the provider.""" device_code_payload = { "client_id": self.oauth2_provider.get_client_id(), "scope": " ".join(self.oauth2_provider.get_oauth_scopes()), @@ -107,8 +115,7 @@ class AuthenticationCommand: return cast(dict[str, Any], response.json()) def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None: - """Display the authentication instructions to the user.""" - + """Print and open the verification URL the user must visit.""" verification_uri = device_code_data.get( "verification_uri_complete", device_code_data.get("verification_uri", "") ) @@ -118,8 +125,7 @@ class AuthenticationCommand: webbrowser.open(verification_uri) def _poll_for_token(self, device_code_data: dict[str, Any]) -> None: - """Polls the server for the token until it is received, or max attempts are reached.""" - + """Poll the token endpoint until authentication completes or times out.""" token_payload = { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code_data["device_code"], @@ -143,7 +149,7 @@ class AuthenticationCommand: style="bold green", ) - self._login_to_tool_repository() + self._post_login() console.print("\n[bold green]Welcome to CrewAI AMP![/bold green]\n") return @@ -161,8 +167,7 @@ class AuthenticationCommand: ) def _validate_and_save_token(self, token_data: dict[str, Any]) -> None: - """Validates the JWT token and saves the token to the token manager.""" - + """Validate the JWT and persist it via the token manager.""" jwt_token = token_data["access_token"] issuer = self.oauth2_provider.get_issuer() jwt_token_data = { @@ -177,39 +182,5 @@ class AuthenticationCommand: expires_at = decoded_token.get("exp", 0) self.token_manager.save_tokens(jwt_token, expires_at) - def _login_to_tool_repository(self) -> None: - """Login to the tool repository.""" - - from crewai.cli.tools.main import ToolCommand - - try: - console.print( - "Now logging you in to the Tool Repository... ", - style="bold blue", - end="", - ) - - ToolCommand().login() - - console.print( - "Success!\n", - style="bold green", - ) - - settings = Settings() - - console.print( - f"You are now authenticated to the tool repository for organization [bold cyan]'{settings.org_name if settings.org_name else settings.org_uuid}'[/bold cyan]", - style="green", - ) - except Exception: - console.print( - "\n[bold yellow]Warning:[/bold yellow] Authentication with the Tool Repository failed.", - style="yellow", - ) - console.print( - "Other features will work normally, but you may experience limitations " - "with downloading and publishing tools." - "\nRun [bold]crewai login[/bold] to try logging in again.\n", - style="yellow", - ) + def _post_login(self) -> None: + """Hook called after a successful login. Override to extend behavior.""" diff --git a/lib/crewai-core/src/crewai_core/auth/providers/__init__.py b/lib/crewai-core/src/crewai_core/auth/providers/__init__.py new file mode 100644 index 000000000..c495fe55b --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/__init__.py @@ -0,0 +1 @@ +"""OAuth2 authentication providers.""" diff --git a/lib/crewai/src/crewai/cli/authentication/providers/auth0.py b/lib/crewai-core/src/crewai_core/auth/providers/auth0.py similarity index 85% rename from lib/crewai/src/crewai/cli/authentication/providers/auth0.py rename to lib/crewai-core/src/crewai_core/auth/providers/auth0.py index b27e3d168..14e5b705f 100644 --- a/lib/crewai/src/crewai/cli/authentication/providers/auth0.py +++ b/lib/crewai-core/src/crewai_core/auth/providers/auth0.py @@ -1,7 +1,13 @@ -from crewai.cli.authentication.providers.base_provider import BaseProvider +"""Auth0 OAuth2 provider.""" + +from __future__ import annotations + +from crewai_core.auth.providers.base_provider import BaseProvider class Auth0Provider(BaseProvider): + """Auth0 OAuth2 provider implementation.""" + def get_authorize_url(self) -> str: return f"https://{self._get_domain()}/oauth/device/code" diff --git a/lib/crewai-core/src/crewai_core/auth/providers/base_provider.py b/lib/crewai-core/src/crewai_core/auth/providers/base_provider.py new file mode 100644 index 000000000..b2332e347 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/providers/base_provider.py @@ -0,0 +1,46 @@ +"""Base OAuth2 provider interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from crewai_core.auth.oauth2 import Oauth2Settings + + +class BaseProvider(ABC): + """Abstract base class for OAuth2 providers.""" + + def __init__(self, settings: Oauth2Settings): + self.settings = settings + + @abstractmethod + def get_authorize_url(self) -> str: + """Return the authorization endpoint URL.""" + + @abstractmethod + def get_token_url(self) -> str: + """Return the token endpoint URL.""" + + @abstractmethod + def get_jwks_url(self) -> str: + """Return the JWKS endpoint URL.""" + + @abstractmethod + def get_issuer(self) -> str: + """Return the OAuth issuer identifier.""" + + @abstractmethod + def get_audience(self) -> str: + """Return the OAuth audience identifier.""" + + @abstractmethod + def get_client_id(self) -> str: + """Return the OAuth client identifier.""" + + def get_required_fields(self) -> list[str]: + """Return provider-specific keys required inside ``Oauth2Settings.extra``.""" + return [] + + def get_oauth_scopes(self) -> list[str]: + """Return the OAuth scopes to request.""" + return ["openid", "profile", "email"] diff --git a/lib/crewai/src/crewai/cli/authentication/providers/entra_id.py b/lib/crewai-core/src/crewai_core/auth/providers/entra_id.py similarity index 85% rename from lib/crewai/src/crewai/cli/authentication/providers/entra_id.py rename to lib/crewai-core/src/crewai_core/auth/providers/entra_id.py index c08ea4ec7..1e5a8a279 100644 --- a/lib/crewai/src/crewai/cli/authentication/providers/entra_id.py +++ b/lib/crewai-core/src/crewai_core/auth/providers/entra_id.py @@ -1,9 +1,15 @@ +"""Entra ID (Azure AD) OAuth2 provider.""" + +from __future__ import annotations + from typing import cast -from crewai.cli.authentication.providers.base_provider import BaseProvider +from crewai_core.auth.providers.base_provider import BaseProvider class EntraIdProvider(BaseProvider): + """Entra ID (Azure AD) OAuth2 provider implementation.""" + def get_authorize_url(self) -> str: return f"{self._base_url()}/oauth2/v2.0/devicecode" diff --git a/lib/crewai/src/crewai/cli/authentication/providers/keycloak.py b/lib/crewai-core/src/crewai_core/auth/providers/keycloak.py similarity index 86% rename from lib/crewai/src/crewai/cli/authentication/providers/keycloak.py rename to lib/crewai-core/src/crewai_core/auth/providers/keycloak.py index e7b076121..6c198660f 100644 --- a/lib/crewai/src/crewai/cli/authentication/providers/keycloak.py +++ b/lib/crewai-core/src/crewai_core/auth/providers/keycloak.py @@ -1,7 +1,13 @@ -from crewai.cli.authentication.providers.base_provider import BaseProvider +"""Keycloak OAuth2 provider.""" + +from __future__ import annotations + +from crewai_core.auth.providers.base_provider import BaseProvider class KeycloakProvider(BaseProvider): + """Keycloak OAuth2 provider implementation.""" + def get_authorize_url(self) -> str: return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/auth/device" diff --git a/lib/crewai/src/crewai/cli/authentication/providers/okta.py b/lib/crewai-core/src/crewai_core/auth/providers/okta.py similarity index 88% rename from lib/crewai/src/crewai/cli/authentication/providers/okta.py rename to lib/crewai-core/src/crewai_core/auth/providers/okta.py index 90f5e2908..5c672ec00 100644 --- a/lib/crewai/src/crewai/cli/authentication/providers/okta.py +++ b/lib/crewai-core/src/crewai_core/auth/providers/okta.py @@ -1,7 +1,13 @@ -from crewai.cli.authentication.providers.base_provider import BaseProvider +"""Okta OAuth2 provider.""" + +from __future__ import annotations + +from crewai_core.auth.providers.base_provider import BaseProvider class OktaProvider(BaseProvider): + """Okta OAuth2 provider implementation.""" + def get_authorize_url(self) -> str: return f"{self._oauth2_base_url()}/v1/device/authorize" diff --git a/lib/crewai/src/crewai/cli/authentication/providers/workos.py b/lib/crewai-core/src/crewai_core/auth/providers/workos.py similarity index 83% rename from lib/crewai/src/crewai/cli/authentication/providers/workos.py rename to lib/crewai-core/src/crewai_core/auth/providers/workos.py index 7cffdf890..2dcd6a1ed 100644 --- a/lib/crewai/src/crewai/cli/authentication/providers/workos.py +++ b/lib/crewai-core/src/crewai_core/auth/providers/workos.py @@ -1,7 +1,13 @@ -from crewai.cli.authentication.providers.base_provider import BaseProvider +"""WorkOS OAuth2 provider.""" + +from __future__ import annotations + +from crewai_core.auth.providers.base_provider import BaseProvider class WorkosProvider(BaseProvider): + """WorkOS OAuth2 provider implementation.""" + def get_authorize_url(self) -> str: return f"https://{self._get_domain()}/oauth2/device_authorization" diff --git a/lib/crewai-core/src/crewai_core/auth/token.py b/lib/crewai-core/src/crewai_core/auth/token.py new file mode 100644 index 000000000..42c40b8fa --- /dev/null +++ b/lib/crewai-core/src/crewai_core/auth/token.py @@ -0,0 +1,17 @@ +"""Authentication token retrieval.""" + +from __future__ import annotations + +from crewai_core.token_manager import TokenManager + + +class AuthError(Exception): + """Raised when authentication fails.""" + + +def get_auth_token() -> str: + """Return the saved authentication token; raise ``AuthError`` if missing.""" + access_token = TokenManager().get_token() + if not access_token: + raise AuthError("No token found, make sure you are logged in") + return access_token diff --git a/lib/crewai/src/crewai/cli/authentication/utils.py b/lib/crewai-core/src/crewai_core/auth/utils.py similarity index 75% rename from lib/crewai/src/crewai/cli/authentication/utils.py rename to lib/crewai-core/src/crewai_core/auth/utils.py index 7311b9d42..cf9ea80c2 100644 --- a/lib/crewai/src/crewai/cli/authentication/utils.py +++ b/lib/crewai-core/src/crewai_core/auth/utils.py @@ -1,24 +1,32 @@ +"""JWT token validation utilities.""" + +from __future__ import annotations + from typing import Any import jwt from jwt import PyJWKClient +from crewai_core.auth.constants import ALGORITHMS + def validate_jwt_token( jwt_token: str, jwks_url: str, issuer: str, audience: str ) -> Any: - """ - Verify the token's signature and claims using PyJWT. - :param jwt_token: The JWT (JWS) string to validate. - :param jwks_url: The URL of the JWKS endpoint. - :param issuer: The expected issuer of the token. - :param audience: The expected audience of the token. - :return: The decoded token. - :raises Exception: If the token is invalid for any reason (e.g., signature mismatch, - expired, incorrect issuer/audience, JWKS fetching error, - missing required claims). - """ + """Verify a JWT's signature and claims using PyJWT. + Args: + jwt_token: The JWT (JWS) string to validate. + jwks_url: The URL of the JWKS endpoint. + issuer: The expected issuer of the token. + audience: The expected audience of the token. + + Returns: + The decoded token. + + Raises: + Exception: If the token is invalid for any reason. + """ try: jwk_client = PyJWKClient(jwks_url) signing_key = jwk_client.get_signing_key_from_jwt(jwt_token) @@ -30,7 +38,7 @@ def validate_jwt_token( return jwt.decode( jwt_token, signing_key.key, - algorithms=["RS256"], + algorithms=ALGORITHMS, audience=audience, issuer=issuer, leeway=10.0, diff --git a/lib/crewai-core/src/crewai_core/constants.py b/lib/crewai-core/src/crewai_core/constants.py new file mode 100644 index 000000000..20ae27c48 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/constants.py @@ -0,0 +1,22 @@ +"""Constants shared by both crewai and crewai-cli.""" + +from __future__ import annotations + +from typing import Final + + +CREWAI_TRAINED_AGENTS_FILE_ENV: Final[str] = "CREWAI_TRAINED_AGENTS_FILE" +TRAINING_DATA_FILE: Final[str] = "training_data.pkl" +TRAINED_AGENTS_DATA_FILE: Final[str] = "trained_agents_data.pkl" +KNOWLEDGE_DIRECTORY: Final[str] = "knowledge" +MAX_FILE_NAME_LENGTH: Final[int] = 255 + +DEFAULT_CREWAI_ENTERPRISE_URL: Final[str] = "https://app.crewai.com" +CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER: Final[str] = "workos" +CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE: Final[str] = ( + "client_01JNJQWBJ4SPFN3SWJM5T7BDG8" +) +CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID: Final[str] = ( + "client_01JYT06R59SP0NXYGD994NFXXX" +) +CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN: Final[str] = "login.crewai.com" diff --git a/lib/crewai-core/src/crewai_core/lock_store.py b/lib/crewai-core/src/crewai_core/lock_store.py new file mode 100644 index 000000000..0f09fa7f6 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/lock_store.py @@ -0,0 +1,89 @@ +"""Centralised lock factory. + +If ``REDIS_URL`` is set and the ``redis`` package is installed, locks are +distributed via ``portalocker.RedisLock``. Otherwise, falls back to the +standard file-based ``portalocker.Lock`` in the system temp dir. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from functools import lru_cache +from hashlib import md5 +import logging +import os +import tempfile +from typing import TYPE_CHECKING, Final + +import portalocker +import portalocker.exceptions + + +if TYPE_CHECKING: + import redis + + +logger = logging.getLogger(__name__) + +_REDIS_URL: str | None = os.environ.get("REDIS_URL") + +_DEFAULT_TIMEOUT: Final[int] = 120 + + +def _redis_available() -> bool: + """Return True if redis is installed and REDIS_URL is set.""" + if not _REDIS_URL: + return False + try: + import redis # noqa: F401 + + return True + except ImportError: + return False + + +@lru_cache(maxsize=1) +def _redis_connection() -> redis.Redis[bytes]: + """Return a cached Redis connection, creating one on first call.""" + from redis import Redis + + if _REDIS_URL is None: + raise ValueError("REDIS_URL environment variable is not set") + return Redis.from_url(_REDIS_URL) + + +@contextmanager +def lock(name: str, *, timeout: float = _DEFAULT_TIMEOUT) -> Iterator[None]: + """Acquire a named lock, yielding while it is held. + + Args: + name: A human-readable lock name (e.g. ``"chromadb_init"``). + Automatically namespaced to avoid collisions. + timeout: Maximum seconds to wait for the lock before raising. + """ + channel = f"crewai:{md5(name.encode(), usedforsecurity=False).hexdigest()}" + + if _redis_available(): + with portalocker.RedisLock( + channel=channel, + connection=_redis_connection(), + timeout=timeout, + ): + yield + else: + lock_dir = tempfile.gettempdir() + lock_path = os.path.join(lock_dir, f"{channel}.lock") + try: + pl = portalocker.Lock(lock_path, timeout=timeout) + pl.acquire() + except portalocker.exceptions.BaseLockException as exc: + raise portalocker.exceptions.LockException( + f"Failed to acquire lock '{name}' at {lock_path} " + f"(timeout={timeout}s). This commonly occurs in " + f"multi-process environments. " + ) from exc + try: + yield + finally: + pl.release() # type: ignore[no-untyped-call] diff --git a/lib/crewai-core/src/crewai_core/paths.py b/lib/crewai-core/src/crewai_core/paths.py new file mode 100644 index 000000000..611265459 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/paths.py @@ -0,0 +1,26 @@ +"""Path management utilities for CrewAI storage and configuration.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import appdirs + + +def get_project_directory_name() -> str: + """Return the current project directory name (or ``CREWAI_STORAGE_DIR``).""" + return os.environ.get("CREWAI_STORAGE_DIR", Path.cwd().name) + + +def db_storage_path() -> str: + """Return the path for SQLite database / app-data storage. + + Creates the directory if it does not exist. + """ + app_name = get_project_directory_name() + app_author = "CrewAI" + + data_dir = Path(appdirs.user_data_dir(app_name, app_author)) + data_dir.mkdir(parents=True, exist_ok=True) + return str(data_dir) diff --git a/lib/crewai/src/crewai/cli/plus_api.py b/lib/crewai-core/src/crewai_core/plus_api.py similarity index 96% rename from lib/crewai/src/crewai/cli/plus_api.py rename to lib/crewai-core/src/crewai_core/plus_api.py index 862ab81e8..39f34e1b8 100644 --- a/lib/crewai/src/crewai/cli/plus_api.py +++ b/lib/crewai-core/src/crewai_core/plus_api.py @@ -1,18 +1,20 @@ +"""CrewAI+ API client — shared by both crewai and crewai-cli.""" + +from __future__ import annotations + import os from typing import Any from urllib.parse import urljoin import httpx -from crewai.cli.config import Settings -from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL -from crewai.utilities.version import get_crewai_version +from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL +from crewai_core.settings import Settings +from crewai_core.version import get_crewai_version class PlusAPI: - """ - This class exposes methods for working with the CrewAI+ API. - """ + """Client for working with the CrewAI+ API.""" TOOLS_RESOURCE = "/crewai_plus/api/v1/tools" ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations" diff --git a/lib/crewai-core/src/crewai_core/printer.py b/lib/crewai-core/src/crewai_core/printer.py new file mode 100644 index 000000000..9f12a2ff6 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/printer.py @@ -0,0 +1,103 @@ +"""Colored console-output utilities and the shared output-suppression flag.""" + +from __future__ import annotations + +from contextvars import ContextVar +from typing import TYPE_CHECKING, Final, Literal, NamedTuple + + +if TYPE_CHECKING: + from _typeshed import SupportsWrite + + +_suppress_console_output: ContextVar[bool] = ContextVar( + "_suppress_console_output", default=False +) + + +def set_suppress_console_output(suppress: bool) -> object: + """Toggle suppression of console output for the current context. + + Returns a token that can be passed to ``ContextVar.reset`` to restore the + previous value. + """ + return _suppress_console_output.set(suppress) + + +def should_suppress_console_output() -> bool: + """Return True if console output should currently be suppressed.""" + return _suppress_console_output.get() + + +PrinterColor = Literal[ + "purple", + "bold_purple", + "green", + "bold_green", + "cyan", + "bold_cyan", + "magenta", + "bold_magenta", + "yellow", + "bold_yellow", + "red", + "blue", + "bold_blue", +] + +_COLOR_CODES: Final[dict[PrinterColor, str]] = { + "purple": "\033[95m", + "bold_purple": "\033[1m\033[95m", + "red": "\033[91m", + "bold_green": "\033[1m\033[92m", + "green": "\033[32m", + "blue": "\033[94m", + "bold_blue": "\033[1m\033[94m", + "yellow": "\033[93m", + "bold_yellow": "\033[1m\033[93m", + "cyan": "\033[96m", + "bold_cyan": "\033[1m\033[96m", + "magenta": "\033[35m", + "bold_magenta": "\033[1m\033[35m", +} + +RESET: Final[str] = "\033[0m" + + +class ColoredText(NamedTuple): + """Text plus an optional color, used for multicolor lines.""" + + text: str + color: PrinterColor | None + + +class Printer: + """Handles colored console output formatting.""" + + @staticmethod + def print( + content: str | list[ColoredText], + color: PrinterColor | None = None, + sep: str | None = " ", + end: str | None = "\n", + file: SupportsWrite[str] | None = None, + flush: Literal[False] = False, + ) -> None: + """Print ``content`` with optional color, honoring suppression context.""" + if should_suppress_console_output(): + return + if isinstance(content, str): + content = [ColoredText(content, color)] + print( + "".join( + f"{_COLOR_CODES[c.color] if c.color else ''}{c.text}{RESET}" + for c in content + ), + sep=sep, + end=end, + file=file, + flush=flush, + ) + + +PRINTER: Printer = Printer() diff --git a/lib/crewai-core/src/crewai_core/project.py b/lib/crewai-core/src/crewai_core/project.py new file mode 100644 index 000000000..29d322304 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/project.py @@ -0,0 +1,109 @@ +"""TOML / pyproject.toml utilities shared by crewai and crewai-cli.""" + +from __future__ import annotations + +from functools import reduce +import sys +from typing import Any + +from rich.console import Console +import tomli + + +if sys.version_info >= (3, 11): + import tomllib + +console = Console() + + +def read_toml(file_path: str = "pyproject.toml") -> dict[str, Any]: + """Read a TOML file from disk and return its parsed contents.""" + with open(file_path, "rb") as f: + return tomli.load(f) + + +def parse_toml(content: str) -> dict[str, Any]: + """Parse a TOML string and return its parsed contents.""" + if sys.version_info >= (3, 11): + return tomllib.loads(content) + return tomli.loads(content) + + +def _get_nested_value(data: dict[str, Any], keys: list[str]) -> Any: + return reduce(dict.__getitem__, keys, data) + + +def _get_project_attribute( + pyproject_path: str, keys: list[str], require: bool +) -> Any | None: + """Look up a dotted attribute path inside ``pyproject_path``. + + The file must declare ``crewai`` in ``[project].dependencies`` for the + lookup to succeed (a guard against running these helpers outside a crewai + project directory). When ``require=True``, missing attributes raise + ``SystemExit`` after printing a friendly error. + """ + attribute = None + + try: + with open(pyproject_path, "r") as f: + pyproject_content = parse_toml(f.read()) + + dependencies = ( + _get_nested_value(pyproject_content, ["project", "dependencies"]) or [] + ) + if not any(True for dep in dependencies if "crewai" in dep): + raise Exception("crewai is not in the dependencies.") + + attribute = _get_nested_value(pyproject_content, keys) + except FileNotFoundError: + console.print(f"Error: {pyproject_path} not found.", style="bold red") + except KeyError: + console.print( + f"Error: {pyproject_path} is not a valid pyproject.toml file.", + style="bold red", + ) + except Exception as e: + if sys.version_info >= (3, 11) and isinstance(e, tomllib.TOMLDecodeError): + console.print( + f"Error: {pyproject_path} is not a valid TOML file.", style="bold red" + ) + else: + console.print( + f"Error reading the pyproject.toml file: {e}", style="bold red" + ) + + if require and not attribute: + console.print( + f"Unable to read '{'.'.join(keys)}' in the pyproject.toml file. " + "Please verify that the file exists and contains the specified attribute.", + style="bold red", + ) + raise SystemExit + + return attribute + + +def get_project_name( + pyproject_path: str = "pyproject.toml", require: bool = False +) -> str | None: + """Return the project name from ``pyproject.toml``.""" + return _get_project_attribute(pyproject_path, ["project", "name"], require=require) + + +def get_project_version( + pyproject_path: str = "pyproject.toml", require: bool = False +) -> str | None: + """Return the project version from ``pyproject.toml``.""" + return _get_project_attribute( + pyproject_path, ["project", "version"], require=require + ) + + +def get_project_description( + pyproject_path: str = "pyproject.toml", require: bool = False +) -> str | None: + """Return the project description from ``pyproject.toml``.""" + return _get_project_attribute( + pyproject_path, ["project", "description"], require=require + ) diff --git a/lib/crewai-core/src/crewai_core/py.typed b/lib/crewai-core/src/crewai_core/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/src/crewai/cli/config.py b/lib/crewai-core/src/crewai_core/settings.py similarity index 79% rename from lib/crewai/src/crewai/cli/config.py rename to lib/crewai-core/src/crewai_core/settings.py index f5b4fe936..083a9e259 100644 --- a/lib/crewai/src/crewai/cli/config.py +++ b/lib/crewai-core/src/crewai_core/settings.py @@ -1,3 +1,7 @@ +"""CrewAI platform settings — shared by crewai and crewai-cli.""" + +from __future__ import annotations + import json from logging import getLogger from pathlib import Path @@ -6,14 +10,14 @@ from typing import Any from pydantic import BaseModel, Field -from crewai.cli.constants import ( +from crewai_core.constants import ( CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, DEFAULT_CREWAI_ENTERPRISE_URL, ) -from crewai.cli.shared.token_manager import TokenManager +from crewai_core.token_manager import TokenManager logger = getLogger(__name__) @@ -22,22 +26,18 @@ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "crewai" / "settings.json" def get_writable_config_path() -> Path | None: - """ - Find a writable location for the config file with fallback options. + """Find a writable location for the config file with fallback options. Tries in order: - 1. Default: ~/.config/crewai/settings.json - 2. Temp directory: /tmp/crewai_settings.json (or OS equivalent) - 3. Current directory: ./crewai_settings.json - 4. In-memory only (returns None) - - Returns: - Path object for writable config location, or None if no writable location found + 1. Default: ``~/.config/crewai/settings.json`` + 2. Temp directory: ``/tmp/crewai_settings.json`` (or OS equivalent) + 3. Current directory: ``./crewai_settings.json`` + 4. In-memory only (returns ``None``) """ fallback_paths = [ - DEFAULT_CONFIG_PATH, # Default location - Path(tempfile.gettempdir()) / "crewai_settings.json", # Temporary directory - Path.cwd() / "crewai_settings.json", # Current working directory + DEFAULT_CONFIG_PATH, + Path(tempfile.gettempdir()) / "crewai_settings.json", + Path.cwd() / "crewai_settings.json", ] for config_path in fallback_paths: @@ -46,7 +46,7 @@ def get_writable_config_path() -> Path | None: test_file = config_path.parent / ".crewai_write_test" try: test_file.write_text("test") - test_file.unlink() # Clean up test file + test_file.unlink() logger.info(f"Using config path: {config_path}") return config_path except Exception: # noqa: S112 @@ -58,7 +58,6 @@ def get_writable_config_path() -> Path | None: return None -# Settings that are related to the user's account USER_SETTINGS_KEYS = [ "tool_repository_username", "tool_repository_password", @@ -66,7 +65,6 @@ USER_SETTINGS_KEYS = [ "org_uuid", ] -# Settings that are related to the CLI CLI_SETTINGS_KEYS = [ "enterprise_base_url", "oauth2_provider", @@ -76,7 +74,6 @@ CLI_SETTINGS_KEYS = [ "oauth2_extra", ] -# Default values for CLI settings DEFAULT_CLI_SETTINGS: dict[str, Any] = { "enterprise_base_url": DEFAULT_CREWAI_ENTERPRISE_URL, "oauth2_provider": CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, @@ -86,13 +83,11 @@ DEFAULT_CLI_SETTINGS: dict[str, Any] = { "oauth2_extra": {}, } -# Readonly settings - cannot be set by the user READONLY_SETTINGS_KEYS = [ "org_name", "org_uuid", ] -# Hidden settings - not displayed by the 'list' command and cannot be set by the user HIDDEN_SETTINGS_KEYS = [ "config_path", "tool_repository_username", @@ -101,6 +96,8 @@ HIDDEN_SETTINGS_KEYS = [ class Settings(BaseModel): + """CrewAI platform settings persisted to ``~/.config/crewai/settings.json``.""" + enterprise_base_url: str | None = Field( default=DEFAULT_CLI_SETTINGS["enterprise_base_url"], description="Base URL of the CrewAI AMP instance", @@ -145,14 +142,12 @@ class Settings(BaseModel): ) def __init__(self, config_path: Path | None = None, **data: dict[str, Any]) -> None: - """Load Settings from config path with fallback support""" + """Load Settings from config path with fallback support.""" if config_path is None: config_path = get_writable_config_path() - # If config_path is None, we're in memory-only mode if config_path is None: merged_data = {**data} - # Dummy path for memory-only mode super().__init__(config_path=Path("/dev/null"), **merged_data) return @@ -160,7 +155,6 @@ class Settings(BaseModel): config_path.parent.mkdir(parents=True, exist_ok=True) except Exception: merged_data = {**data} - # Dummy path for memory-only mode super().__init__(config_path=Path("/dev/null"), **merged_data) return @@ -176,19 +170,19 @@ class Settings(BaseModel): super().__init__(config_path=config_path, **merged_data) def clear_user_settings(self) -> None: - """Clear all user settings""" + """Clear all user settings.""" self._reset_user_settings() self.dump() def reset(self) -> None: - """Reset all settings to default values""" + """Reset all settings to default values.""" self._reset_user_settings() self._reset_cli_settings() self._clear_auth_tokens() self.dump() def dump(self) -> None: - """Save current settings to settings.json""" + """Save current settings to settings.json.""" if str(self.config_path) == "/dev/null": return @@ -207,15 +201,15 @@ class Settings(BaseModel): pass def _reset_user_settings(self) -> None: - """Reset all user settings to default values""" + """Reset all user settings to default values.""" for key in USER_SETTINGS_KEYS: setattr(self, key, None) def _reset_cli_settings(self) -> None: - """Reset all CLI settings to default values""" + """Reset all CLI settings to default values.""" for key in CLI_SETTINGS_KEYS: setattr(self, key, DEFAULT_CLI_SETTINGS.get(key)) def _clear_auth_tokens(self) -> None: - """Clear all authentication tokens""" + """Clear all authentication tokens.""" TokenManager().clear_tokens() diff --git a/lib/crewai-core/src/crewai_core/telemetry.py b/lib/crewai-core/src/crewai_core/telemetry.py new file mode 100644 index 000000000..20b990632 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/telemetry.py @@ -0,0 +1,272 @@ +"""Anonymous telemetry collection — base implementation. + +This module is the leaf telemetry layer used by both ``crewai`` (which extends +it with framework-specific spans + event-bus signal hooks) and ``crewai-cli`` +(which uses it directly to emit deployment / template / flow-creation spans). + +No prompts, task descriptions, agent backstories/goals, responses, or sensitive +data are collected. +""" + +from __future__ import annotations + +import asyncio +import atexit +from collections.abc import Callable +import contextlib +import logging +import os +import threading +from typing import Any, Final + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + SpanExportResult, +) +from opentelemetry.trace import Span, Status, StatusCode +from typing_extensions import Self + + +logger = logging.getLogger(__name__) + + +CREWAI_TELEMETRY_BASE_URL: Final[str] = "https://telemetry.crewai.com:4319" +CREWAI_TELEMETRY_SERVICE_NAME: Final[str] = "crewAI-telemetry" + + +def close_span(span: Span) -> None: + """Set span status to OK and end it.""" + span.set_status(Status(StatusCode.OK)) + span.end() + + +@contextlib.contextmanager +def suppress_warnings() -> Any: + """Suppress noisy warnings during otel provider setup.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + yield + + +class SafeOTLPSpanExporter(OTLPSpanExporter): + """OTLP exporter that swallows export failures so telemetry never crashes the app.""" + + def export(self, spans: Any) -> SpanExportResult: + try: + return super().export(spans) + except Exception as e: + logger.debug("Telemetry export failed: %s", e) + return SpanExportResult.FAILURE + + +class Telemetry: + """Base telemetry: OTLP setup + the spans needed by the CLI. + + crewai's runtime extends this with crew/agent/task/tool/flow execution spans + and event-bus signal handlers (see ``crewai.telemetry.telemetry``). + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls) -> Self: + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self) -> None: + if hasattr(self, "_initialized") and self._initialized: + return + + self.ready: bool = False + self.trace_set: bool = False + self._initialized: bool = True + + if self._is_telemetry_disabled(): + return + + try: + self.resource = Resource( + attributes={SERVICE_NAME: CREWAI_TELEMETRY_SERVICE_NAME}, + ) + with suppress_warnings(): + self.provider = TracerProvider(resource=self.resource) + + processor = BatchSpanProcessor( + SafeOTLPSpanExporter( + endpoint=f"{CREWAI_TELEMETRY_BASE_URL}/v1/traces", + timeout=30, + ) + ) + + self.provider.add_span_processor(processor) + self._register_shutdown_handlers() + self.ready = True + except Exception as e: + if isinstance( + e, + (SystemExit, KeyboardInterrupt, GeneratorExit, asyncio.CancelledError), + ): + raise + self.ready = False + + @classmethod + def _is_telemetry_disabled(cls) -> bool: + return ( + os.getenv("OTEL_SDK_DISABLED", "false").lower() == "true" + or os.getenv("CREWAI_DISABLE_TELEMETRY", "false").lower() == "true" + or os.getenv("CREWAI_DISABLE_TRACKING", "false").lower() == "true" + ) + + def _should_execute_telemetry(self) -> bool: + return self.ready and not self._is_telemetry_disabled() + + def _register_shutdown_handlers(self) -> None: + """Register an atexit flush. Subclasses may extend with signal hooks.""" + atexit.register(self._shutdown) + + def _shutdown(self) -> None: + if not self.ready: + return + try: + self.provider.force_flush(timeout_millis=5000) + self.provider.shutdown() + self.ready = False + except Exception as e: + logger.debug("Telemetry shutdown failed: %s", e) + + def set_tracer(self) -> None: + """Install our TracerProvider as the global one (idempotent).""" + if self.ready and not self.trace_set: + try: + with suppress_warnings(): + trace.set_tracer_provider(self.provider) + self.trace_set = True + except Exception as e: + logger.debug("Failed to set tracer provider: %s", e) + self.ready = False + self.trace_set = False + + def _safe_telemetry_operation( + self, operation: Callable[[], Span | None] + ) -> Span | None: + """Run a span-returning telemetry operation, swallowing failures.""" + if not self._should_execute_telemetry(): + return None + try: + return operation() + except Exception as e: + logger.debug("Telemetry operation failed: %s", e) + return None + + def _safe_telemetry_procedure(self, operation: Callable[[], None]) -> None: + """Run a void telemetry procedure, swallowing failures.""" + if not self._should_execute_telemetry(): + return + try: + operation() + except Exception as e: + logger.debug("Telemetry operation failed: %s", e) + + def _add_attribute(self, span: Span | None, key: str, value: Any) -> None: + if span is None: + return + + def _operation() -> None: + span.set_attribute(key, value) + + self._safe_telemetry_procedure(_operation) + + # --- CLI-facing spans --------------------------------------------------- + + def deploy_signup_error_span(self) -> None: + """Records when an error occurs during the deployment signup process.""" + + def _operation() -> None: + tracer = trace.get_tracer("crewai.telemetry") + span = tracer.start_span("Deploy Signup Error") + close_span(span) + + self._safe_telemetry_procedure(_operation) + + def start_deployment_span(self, uuid: str | None = None) -> None: + """Records the start of a deployment process.""" + + def _operation() -> None: + tracer = trace.get_tracer("crewai.telemetry") + span = tracer.start_span("Start Deployment") + if uuid: + self._add_attribute(span, "uuid", uuid) + close_span(span) + + self._safe_telemetry_procedure(_operation) + + def create_crew_deployment_span(self) -> None: + """Records the creation of a new crew deployment.""" + + def _operation() -> None: + tracer = trace.get_tracer("crewai.telemetry") + span = tracer.start_span("Create Crew Deployment") + close_span(span) + + self._safe_telemetry_procedure(_operation) + + def get_crew_logs_span( + self, uuid: str | None, log_type: str = "deployment" + ) -> None: + """Records the retrieval of crew logs.""" + + def _operation() -> None: + tracer = trace.get_tracer("crewai.telemetry") + span = tracer.start_span("Get Crew Logs") + self._add_attribute(span, "log_type", log_type) + if uuid: + self._add_attribute(span, "uuid", uuid) + close_span(span) + + self._safe_telemetry_procedure(_operation) + + def remove_crew_span(self, uuid: str | None = None) -> None: + """Records the removal of a crew.""" + + def _operation() -> None: + tracer = trace.get_tracer("crewai.telemetry") + span = tracer.start_span("Remove Crew") + if uuid: + self._add_attribute(span, "uuid", uuid) + close_span(span) + + self._safe_telemetry_procedure(_operation) + + def flow_creation_span(self, flow_name: str) -> None: + """Records the creation of a new flow.""" + + def _operation() -> None: + tracer = trace.get_tracer("crewai.telemetry") + span = tracer.start_span("Flow Creation") + self._add_attribute(span, "flow_name", flow_name) + close_span(span) + + self._safe_telemetry_procedure(_operation) + + def template_installed_span(self, template_name: str) -> None: + """Records when a template is downloaded and installed.""" + from crewai_core.version import get_crewai_version + + def _operation() -> None: + tracer = trace.get_tracer("crewai.telemetry") + span = tracer.start_span("Template Installed") + self._add_attribute(span, "crewai_version", get_crewai_version()) + self._add_attribute(span, "template_name", template_name) + close_span(span) + + self._safe_telemetry_procedure(_operation) diff --git a/lib/crewai/src/crewai/cli/shared/token_manager.py b/lib/crewai-core/src/crewai_core/token_manager.py similarity index 77% rename from lib/crewai/src/crewai/cli/shared/token_manager.py rename to lib/crewai-core/src/crewai_core/token_manager.py index 02c176924..06f2e7b18 100644 --- a/lib/crewai/src/crewai/cli/shared/token_manager.py +++ b/lib/crewai-core/src/crewai_core/token_manager.py @@ -1,3 +1,7 @@ +"""Encrypted token storage shared by crewai and crewai-cli.""" + +from __future__ import annotations + from datetime import datetime import json import os @@ -13,7 +17,7 @@ _FERNET_KEY_LENGTH: Final[Literal[44]] = 44 class TokenManager: - """Manages encrypted token storage.""" + """Manages encrypted token storage on disk under platform-appropriate paths.""" def __init__(self, file_path: str = "tokens.enc") -> None: """Initialize the TokenManager. @@ -26,11 +30,7 @@ class TokenManager: self.fernet = Fernet(self.key) def _get_or_create_key(self) -> bytes: - """Get or create the encryption key. - - Returns: - The encryption key as bytes. - """ + """Get or create the encryption key.""" key_filename: str = "secret.key" key = self._read_secure_file(key_filename) @@ -63,11 +63,7 @@ class TokenManager: self._atomic_write_secure_file(self.file_path, encrypted_data) def get_token(self) -> str | None: - """Get the access token if it is valid and not expired. - - Returns: - The access token if valid and not expired, otherwise None. - """ + """Return the access token if valid and not expired, else None.""" encrypted_data = self._read_secure_file(self.file_path) if encrypted_data is None: return None @@ -82,16 +78,12 @@ class TokenManager: return cast(str | None, data.get("access_token")) def clear_tokens(self) -> None: - """Clear the stored tokens.""" + """Remove the stored token file (no-op if absent).""" self._delete_secure_file(self.file_path) @staticmethod def _get_secure_storage_path() -> Path: - """Get the secure storage path based on the operating system. - - Returns: - The secure storage path. - """ + """Platform-appropriate per-user credential directory (mode 0o700).""" if sys.platform == "win32": base_path = os.environ.get("LOCALAPPDATA") elif sys.platform == "darwin": @@ -107,15 +99,7 @@ class TokenManager: return storage_path def _atomic_create_secure_file(self, filename: str, content: bytes) -> bool: - """Create a file only if it doesn't exist. - - Args: - filename: The name of the file. - content: The content to write. - - Returns: - True if file was created, False if it already exists. - """ + """Create a file only if it doesn't already exist.""" storage_path = self._get_secure_storage_path() file_path = storage_path / filename @@ -130,12 +114,7 @@ class TokenManager: return False def _atomic_write_secure_file(self, filename: str, content: bytes) -> None: - """Write content to a secure file. - - Args: - filename: The name of the file. - content: The content to write. - """ + """Write content to a secure file via tempfile + os.replace.""" storage_path = self._get_secure_storage_path() file_path = storage_path / filename @@ -155,14 +134,7 @@ class TokenManager: raise def _read_secure_file(self, filename: str) -> bytes | None: - """Read the content of a secure file. - - Args: - filename: The name of the file. - - Returns: - The content of the file if it exists, otherwise None. - """ + """Read raw bytes from a secure file, or None if absent.""" storage_path = self._get_secure_storage_path() file_path = storage_path / filename @@ -173,14 +145,7 @@ class TokenManager: return None def _delete_secure_file(self, filename: str) -> None: - """Delete a secure file. - - Args: - filename: The name of the file. - """ + """Delete a secure file (no-op if absent).""" storage_path = self._get_secure_storage_path() file_path = storage_path / filename - try: - file_path.unlink() - except FileNotFoundError: - pass + file_path.unlink(missing_ok=True) diff --git a/lib/crewai-core/src/crewai_core/tool_credentials.py b/lib/crewai-core/src/crewai_core/tool_credentials.py new file mode 100644 index 000000000..b4789cd19 --- /dev/null +++ b/lib/crewai-core/src/crewai_core/tool_credentials.py @@ -0,0 +1,56 @@ +"""Tool-repository credential helpers shared by crewai and crewai-cli.""" + +from __future__ import annotations + +import os +from typing import Any + +from crewai_core.project import read_toml +from crewai_core.settings import Settings + + +def build_env_with_tool_repository_credentials( + repository_handle: str, +) -> dict[str, Any]: + """Return a copy of ``os.environ`` augmented with UV_INDEX_* credentials + for ``repository_handle``. + + The handle is normalized to upper-case with hyphens replaced by underscores + (matching ``uv``'s env-var convention). + """ + repository_handle = repository_handle.upper().replace("-", "_") + settings = Settings() + + env = os.environ.copy() + env[f"UV_INDEX_{repository_handle}_USERNAME"] = str( + settings.tool_repository_username or "" + ) + env[f"UV_INDEX_{repository_handle}_PASSWORD"] = str( + settings.tool_repository_password or "" + ) + + return env + + +def build_env_with_all_tool_credentials() -> dict[str, Any]: + """Return ``os.environ`` augmented with UV_INDEX_* credentials for every + private index referenced under ``[tool.uv.sources]`` in ``pyproject.toml``. + + Errors reading ``pyproject.toml`` are swallowed — the un-augmented + environment is returned in that case. + """ + env = os.environ.copy() + try: + pyproject_data = read_toml() + sources = pyproject_data.get("tool", {}).get("uv", {}).get("sources", {}) + + for source_config in sources.values(): + if isinstance(source_config, dict): + index = source_config.get("index") + if index: + index_env = build_env_with_tool_repository_credentials(index) + env.update(index_env) + except Exception: # noqa: S110 + pass + + return env diff --git a/lib/crewai-core/src/crewai_core/user_data.py b/lib/crewai-core/src/crewai_core/user_data.py new file mode 100644 index 000000000..91e4f35fe --- /dev/null +++ b/lib/crewai-core/src/crewai_core/user_data.py @@ -0,0 +1,91 @@ +"""Persistent per-user data + tracing-consent helpers. + +This is the single source of truth for the ``.crewai_user.json`` file used by +both crewai (to record trace consent) and crewai-cli (to read/write it via +``crewai traces enable/disable/status``). +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Any, cast + +from crewai_core.lock_store import lock as store_lock +from crewai_core.paths import db_storage_path + + +logger = logging.getLogger(__name__) + + +def _user_data_file() -> Path: + base = Path(db_storage_path()) + base.mkdir(parents=True, exist_ok=True) + return base / ".crewai_user.json" + + +def _user_data_lock_name() -> str: + """Return a stable lock name for the user data file.""" + return f"file:{os.path.realpath(_user_data_file())}" + + +def _load_user_data() -> dict[str, Any]: + """Read the user-data JSON file, returning ``{}`` on missing/corrupt.""" + p = _user_data_file() + if p.exists(): + try: + return cast(dict[str, Any], json.loads(p.read_text())) + except (json.JSONDecodeError, OSError, PermissionError) as e: + logger.warning("Failed to load user data: %s", e) + return {} + + +def _save_user_data(data: dict[str, Any]) -> None: + """Write the full user-data dict, ignoring write errors with a warning.""" + try: + p = _user_data_file() + p.write_text(json.dumps(data, indent=2)) + except (OSError, PermissionError) as e: + logger.warning("Failed to save user data: %s", e) + + +def update_user_data(updates: dict[str, Any]) -> None: + """Atomically read-modify-write the user data file under a file lock. + + Args: + updates: Key-value pairs to merge into the existing user data. + """ + try: + with store_lock(_user_data_lock_name()): + data = _load_user_data() + data.update(updates) + _save_user_data(data) + except (OSError, PermissionError) as e: + logger.warning("Failed to update user data: %s", e) + + +def has_user_declined_tracing() -> bool: + """Return True if the user has explicitly declined trace collection.""" + data = _load_user_data() + if data.get("first_execution_done", False): + return data.get("trace_consent", False) is False + return False + + +def is_tracing_enabled() -> bool: + """Return True if tracing should currently be active. + + Mirrors the runtime gate (``crewai.events.listeners.tracing.utils. + should_enable_tracing``): ``CREWAI_TRACING_ENABLED=true`` always activates; + otherwise recorded consent activates; otherwise off. Used by + ``crewai traces status`` so the displayed state matches what crews and + flows actually do. + """ + if os.getenv("CREWAI_TRACING_ENABLED", "false").lower() in ("true", "1"): + return True + if has_user_declined_tracing(): + return False + data = _load_user_data() + return data.get("trace_consent", False) is not False diff --git a/lib/crewai/src/crewai/cli/version.py b/lib/crewai-core/src/crewai_core/version.py similarity index 75% rename from lib/crewai/src/crewai/cli/version.py rename to lib/crewai-core/src/crewai_core/version.py index 232aa2423..e51fe51bd 100644 --- a/lib/crewai/src/crewai/cli/version.py +++ b/lib/crewai-core/src/crewai_core/version.py @@ -1,8 +1,16 @@ -"""Version utilities for CrewAI CLI.""" +"""Version utilities — installed version + PyPI freshness/yank checks. + +Shared by both ``crewai`` and ``crewai-cli`` so the PyPI-checking logic lives +in one place. Frontends (``crewai version`` CLI, banner printer) consume the +helpers here without re-implementing them. +""" + +from __future__ import annotations from collections.abc import Mapping from datetime import datetime, timedelta -from functools import lru_cache +from functools import cache, lru_cache +import importlib.metadata import json from pathlib import Path from typing import Any @@ -12,22 +20,34 @@ from urllib.error import URLError import appdirs from packaging.version import InvalidVersion, Version, parse -from crewai.utilities.version import get_crewai_version + +@cache +def get_crewai_version() -> str: + """Return the installed crewAI version string. + + Falls back to ``"unknown"`` when neither crewai nor crewai-core are + pip-installed (e.g. running directly from a source checkout). + """ + try: + return importlib.metadata.version("crewai") + except importlib.metadata.PackageNotFoundError: + pass + try: + return importlib.metadata.version("crewai-core") + except importlib.metadata.PackageNotFoundError: + return "unknown" @lru_cache(maxsize=1) def _get_cache_file() -> Path: - """Get the path to the version cache file. - - Cached to avoid repeated filesystem operations. - """ + """Return the path to the version cache file, creating the dir if needed.""" cache_dir = Path(appdirs.user_cache_dir("crewai")) cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir / "version_cache.json" def _is_cache_valid(cache_data: Mapping[str, Any]) -> bool: - """Check if the cache is still valid, less than 24 hours old.""" + """Return True if the cache is less than 24 hours old.""" if "timestamp" not in cache_data: return False @@ -41,14 +61,7 @@ def _is_cache_valid(cache_data: Mapping[str, Any]) -> bool: def _find_latest_non_yanked_version( releases: Mapping[str, list[dict[str, Any]]], ) -> str | None: - """Find the latest non-yanked version from PyPI releases data. - - Args: - releases: PyPI releases dict mapping version strings to file info lists. - - Returns: - The latest non-yanked version string, or None if all versions are yanked. - """ + """Return the latest non-prerelease, non-yanked version from PyPI releases.""" best_version: Version | None = None best_version_str: str | None = None @@ -79,15 +92,7 @@ def _is_version_yanked( version_str: str, releases: Mapping[str, list[dict[str, Any]]], ) -> tuple[bool, str]: - """Check if a specific version is yanked. - - Args: - version_str: The version string to check. - releases: PyPI releases dict mapping version strings to file info lists. - - Returns: - Tuple of (is_yanked, yanked_reason). - """ + """Return ``(yanked, reason)`` for ``version_str`` against PyPI releases.""" files = releases.get(version_str, []) if not files: return False, "" @@ -105,14 +110,7 @@ def _is_version_yanked( def get_latest_version_from_pypi(timeout: int = 2) -> str | None: - """Get the latest non-yanked version of CrewAI from PyPI. - - Args: - timeout: Request timeout in seconds. - - Returns: - Latest non-yanked version string or None if unable to fetch. - """ + """Return the latest non-yanked PyPI version of CrewAI, or ``None`` on failure.""" cache_file = _get_cache_file() if cache_file.exists(): try: @@ -149,13 +147,7 @@ def get_latest_version_from_pypi(timeout: int = 2) -> str | None: def is_current_version_yanked() -> tuple[bool, str]: - """Check if the currently installed version has been yanked on PyPI. - - Reads from cache if available, otherwise triggers a fetch. - - Returns: - Tuple of (is_yanked, yanked_reason). - """ + """Return ``(yanked, reason)`` for the currently installed version.""" cache_file = _get_cache_file() if cache_file.exists(): try: @@ -183,23 +175,14 @@ def is_current_version_yanked() -> tuple[bool, str]: def check_version() -> tuple[str, str | None]: - """Check current and latest versions. - - Returns: - Tuple of (current_version, latest_version). - latest_version is None if unable to fetch from PyPI. - """ + """Return ``(current_version, latest_version)``; latest is ``None`` on fetch failure.""" current = get_crewai_version() latest = get_latest_version_from_pypi() return current, latest def is_newer_version_available() -> tuple[bool, str, str | None]: - """Check if a newer version is available. - - Returns: - Tuple of (is_newer, current_version, latest_version). - """ + """Return ``(is_newer, current_version, latest_version)``.""" current, latest = check_version() if latest is None: diff --git a/lib/crewai-core/tests/__init__.py b/lib/crewai-core/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai-core/tests/test_smoke.py b/lib/crewai-core/tests/test_smoke.py new file mode 100644 index 000000000..93b2e46f9 --- /dev/null +++ b/lib/crewai-core/tests/test_smoke.py @@ -0,0 +1,96 @@ +"""Smoke tests for the crewai-core leaf modules.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from crewai_core import ( + constants, + lock_store, + paths, + printer, + user_data, + version, +) +import pytest + + +def test_version_returns_string() -> None: + v = version.get_crewai_version() + assert isinstance(v, str) and v + + +def test_paths_creates_storage_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CREWAI_STORAGE_DIR", str(tmp_path / "store")) + monkeypatch.setattr( + "crewai_core.paths.appdirs.user_data_dir", + lambda app, author: str(tmp_path / app), + ) + out = paths.db_storage_path() + assert Path(out).exists() + + +def test_constants_exposes_env_keys() -> None: + assert constants.CREWAI_TRAINED_AGENTS_FILE_ENV == "CREWAI_TRAINED_AGENTS_FILE" + + +def test_printer_emits_when_not_suppressed(capsys: pytest.CaptureFixture[str]) -> None: + printer.PRINTER.print("hello", color="green") + out = capsys.readouterr().out + assert "hello" in out + + +def test_printer_respects_suppression(capsys: pytest.CaptureFixture[str]) -> None: + token = printer.set_suppress_console_output(True) + try: + printer.PRINTER.print("hidden") + finally: + printer._suppress_console_output.reset(token) # type: ignore[arg-type] + assert "hidden" not in capsys.readouterr().out + + +def test_lock_acquires_and_releases() -> None: + with lock_store.lock("crewai_core.tests.smoke", timeout=5): + pass + + +def test_user_data_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CREWAI_STORAGE_DIR", "crewai_core_test_user_data") + monkeypatch.setattr( + "crewai_core.paths.appdirs.user_data_dir", + lambda app, author: str(tmp_path / app), + ) + user_data.update_user_data({"trace_consent": True, "first_execution_done": True}) + data = user_data._load_user_data() + assert data == {"trace_consent": True, "first_execution_done": True} + assert user_data.has_user_declined_tracing() is False + monkeypatch.setenv("CREWAI_TRACING_ENABLED", "true") + assert user_data.is_tracing_enabled() is True + monkeypatch.delenv("CREWAI_TRACING_ENABLED", raising=False) + assert ( + user_data.is_tracing_enabled() is True + ) # consent alone enables (matches runtime) + + +def test_user_data_decline_blocks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CREWAI_STORAGE_DIR", "crewai_core_test_decline") + monkeypatch.setattr( + "crewai_core.paths.appdirs.user_data_dir", + lambda app, author: str(tmp_path / app), + ) + user_data.update_user_data({"trace_consent": False, "first_execution_done": True}) + assert user_data.has_user_declined_tracing() is True + monkeypatch.delenv("CREWAI_TRACING_ENABLED", raising=False) + assert user_data.is_tracing_enabled() is False + monkeypatch.setenv("CREWAI_TRACING_ENABLED", "true") + assert user_data.is_tracing_enabled() is True # env-var override (matches runtime) + + +def test_unused_var_warning_silenced() -> None: + # Touch os to keep the import (used by env-var fixtures above) + assert os.environ is not None diff --git a/lib/crewai-tools/src/crewai_tools/tools/couchbase_tool/couchbase_tool.py b/lib/crewai-tools/src/crewai_tools/tools/couchbase_tool/couchbase_tool.py index 5d86b9389..936c30ed8 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/couchbase_tool/couchbase_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/couchbase_tool/couchbase_tool.py @@ -93,7 +93,7 @@ class CouchbaseFTSVectorSearchTool(BaseTool): scope_collection_map[scope.name].append(collection.name) # Check if the scope exists - if self.scope_name not in scope_collection_map.keys(): + if self.scope_name not in scope_collection_map: raise ValueError( f"Scope {self.scope_name} not found in Couchbase " f"bucket {self.bucket_name}" diff --git a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/__init__.py index e04396bfb..353265244 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/__init__.py @@ -5,6 +5,7 @@ from crewai_tools.tools.daytona_sandbox_tool.daytona_python_tool import ( DaytonaPythonTool, ) + __all__ = [ "DaytonaBaseTool", "DaytonaExecTool", diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index fc48e6661..0ee41f12d 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -8,6 +8,8 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ + "crewai-core==1.14.5a2", + "crewai-cli==1.14.5a2", # Core Dependencies "pydantic>=2.11.9,<2.13", "openai>=2.30.0,<3", @@ -40,7 +42,6 @@ dependencies = [ "pydantic-settings~=2.10.1", "httpx~=0.28.1", "mcp~=1.26.0", - "uv~=0.11.6", "aiosqlite~=0.21.0", "pyyaml~=6.0", "aiofiles~=24.1.0", @@ -113,10 +114,6 @@ qdrant-edge = [ ] -[project.scripts] -crewai = "crewai.cli.cli:crewai" - - [tool.uv] exclude-newer = "3 days" diff --git a/lib/crewai/src/crewai/a2a/auth/utils.py b/lib/crewai/src/crewai/a2a/auth/utils.py index 3e8de3e0d..6968e930b 100644 --- a/lib/crewai/src/crewai/a2a/auth/utils.py +++ b/lib/crewai/src/crewai/a2a/auth/utils.py @@ -168,7 +168,7 @@ def validate_auth_against_agent_card( first_security_req = agent_card.security[0] if agent_card.security else {} - for scheme_name in first_security_req.keys(): + for scheme_name in first_security_req: security_scheme_wrapper = agent_card.security_schemes.get(scheme_name) if not security_scheme_wrapper: continue diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py index f4d55fe80..1dac07606 100644 --- a/lib/crewai/src/crewai/agents/crew_agent_executor.py +++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py @@ -15,6 +15,7 @@ import inspect import logging from typing import TYPE_CHECKING, Annotated, Any, Literal, cast +from crewai_core.printer import PRINTER from pydantic import ( AliasChoices, BaseModel, @@ -69,7 +70,6 @@ from crewai.utilities.agent_utils import ( from crewai.utilities.constants import TRAINING_DATA_FILE from crewai.utilities.file_store import aget_all_files, get_all_files from crewai.utilities.i18n import I18N_DEFAULT -from crewai.utilities.printer import PRINTER from crewai.utilities.string_utils import sanitize_tool_name from crewai.utilities.token_counter_callback import TokenCalcHandler from crewai.utilities.tool_utils import ( diff --git a/lib/crewai/src/crewai/agents/step_executor.py b/lib/crewai/src/crewai/agents/step_executor.py index df834e3e4..5fe517389 100644 --- a/lib/crewai/src/crewai/agents/step_executor.py +++ b/lib/crewai/src/crewai/agents/step_executor.py @@ -18,6 +18,7 @@ import json import time from typing import TYPE_CHECKING, Any, cast +from crewai_core.printer import PRINTER from pydantic import BaseModel from crewai.agents.parser import AgentAction, AgentFinish @@ -40,7 +41,6 @@ from crewai.utilities.agent_utils import ( ) from crewai.utilities.i18n import I18N_DEFAULT from crewai.utilities.planning_types import TodoItem -from crewai.utilities.printer import PRINTER from crewai.utilities.step_execution_context import StepExecutionContext, StepResult from crewai.utilities.string_utils import sanitize_tool_name from crewai.utilities.tool_utils import execute_tool_and_check_finality diff --git a/lib/crewai/src/crewai/auth/__init__.py b/lib/crewai/src/crewai/auth/__init__.py new file mode 100644 index 000000000..c30b37f9c --- /dev/null +++ b/lib/crewai/src/crewai/auth/__init__.py @@ -0,0 +1,22 @@ +"""Authentication utilities — re-exported from ``crewai_core.auth``.""" + +from __future__ import annotations + +from crewai_core.auth import ( + AuthError as AuthError, + AuthenticationCommand as AuthenticationCommand, + Oauth2Settings as Oauth2Settings, + ProviderFactory as ProviderFactory, + get_auth_token as get_auth_token, + validate_jwt_token as validate_jwt_token, +) + + +__all__ = [ + "AuthError", + "AuthenticationCommand", + "Oauth2Settings", + "ProviderFactory", + "get_auth_token", + "validate_jwt_token", +] diff --git a/lib/crewai/src/crewai/auth/constants.py b/lib/crewai/src/crewai/auth/constants.py new file mode 100644 index 000000000..b1dae41aa --- /dev/null +++ b/lib/crewai/src/crewai/auth/constants.py @@ -0,0 +1,8 @@ +"""Re-export of authentication constants from ``crewai_core.auth.constants``.""" + +from __future__ import annotations + +from crewai_core.auth.constants import ALGORITHMS as ALGORITHMS + + +__all__ = ["ALGORITHMS"] diff --git a/lib/crewai/src/crewai/auth/oauth2.py b/lib/crewai/src/crewai/auth/oauth2.py new file mode 100644 index 000000000..8e05ebff0 --- /dev/null +++ b/lib/crewai/src/crewai/auth/oauth2.py @@ -0,0 +1,12 @@ +"""Re-exports of OAuth2 primitives from ``crewai_core.auth.oauth2``.""" + +from __future__ import annotations + +from crewai_core.auth.oauth2 import ( + AuthenticationCommand as AuthenticationCommand, + Oauth2Settings as Oauth2Settings, + ProviderFactory as ProviderFactory, +) + + +__all__ = ["AuthenticationCommand", "Oauth2Settings", "ProviderFactory"] diff --git a/lib/crewai/src/crewai/auth/providers/__init__.py b/lib/crewai/src/crewai/auth/providers/__init__.py new file mode 100644 index 000000000..723579c03 --- /dev/null +++ b/lib/crewai/src/crewai/auth/providers/__init__.py @@ -0,0 +1 @@ +"""OAuth2 authentication providers — re-exported from ``crewai_core.auth.providers``.""" diff --git a/lib/crewai/src/crewai/auth/providers/auth0.py b/lib/crewai/src/crewai/auth/providers/auth0.py new file mode 100644 index 000000000..110b4784a --- /dev/null +++ b/lib/crewai/src/crewai/auth/providers/auth0.py @@ -0,0 +1,8 @@ +"""Re-export of ``Auth0Provider`` from ``crewai_core.auth.providers.auth0``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.auth0 import Auth0Provider as Auth0Provider + + +__all__ = ["Auth0Provider"] diff --git a/lib/crewai/src/crewai/auth/providers/base_provider.py b/lib/crewai/src/crewai/auth/providers/base_provider.py new file mode 100644 index 000000000..d82bfd15a --- /dev/null +++ b/lib/crewai/src/crewai/auth/providers/base_provider.py @@ -0,0 +1,8 @@ +"""Re-export of ``BaseProvider`` from ``crewai_core.auth.providers.base_provider``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.base_provider import BaseProvider as BaseProvider + + +__all__ = ["BaseProvider"] diff --git a/lib/crewai/src/crewai/auth/providers/entra_id.py b/lib/crewai/src/crewai/auth/providers/entra_id.py new file mode 100644 index 000000000..1ea10db78 --- /dev/null +++ b/lib/crewai/src/crewai/auth/providers/entra_id.py @@ -0,0 +1,8 @@ +"""Re-export of ``EntraIdProvider`` from ``crewai_core.auth.providers.entra_id``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.entra_id import EntraIdProvider as EntraIdProvider + + +__all__ = ["EntraIdProvider"] diff --git a/lib/crewai/src/crewai/auth/providers/keycloak.py b/lib/crewai/src/crewai/auth/providers/keycloak.py new file mode 100644 index 000000000..4bbf0be53 --- /dev/null +++ b/lib/crewai/src/crewai/auth/providers/keycloak.py @@ -0,0 +1,8 @@ +"""Re-export of ``KeycloakProvider`` from ``crewai_core.auth.providers.keycloak``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.keycloak import KeycloakProvider as KeycloakProvider + + +__all__ = ["KeycloakProvider"] diff --git a/lib/crewai/src/crewai/auth/providers/okta.py b/lib/crewai/src/crewai/auth/providers/okta.py new file mode 100644 index 000000000..530549be5 --- /dev/null +++ b/lib/crewai/src/crewai/auth/providers/okta.py @@ -0,0 +1,8 @@ +"""Re-export of ``OktaProvider`` from ``crewai_core.auth.providers.okta``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.okta import OktaProvider as OktaProvider + + +__all__ = ["OktaProvider"] diff --git a/lib/crewai/src/crewai/auth/providers/workos.py b/lib/crewai/src/crewai/auth/providers/workos.py new file mode 100644 index 000000000..b31c72cae --- /dev/null +++ b/lib/crewai/src/crewai/auth/providers/workos.py @@ -0,0 +1,8 @@ +"""Re-export of ``WorkosProvider`` from ``crewai_core.auth.providers.workos``.""" + +from __future__ import annotations + +from crewai_core.auth.providers.workos import WorkosProvider as WorkosProvider + + +__all__ = ["WorkosProvider"] diff --git a/lib/crewai/src/crewai/auth/token.py b/lib/crewai/src/crewai/auth/token.py new file mode 100644 index 000000000..5bb6b656f --- /dev/null +++ b/lib/crewai/src/crewai/auth/token.py @@ -0,0 +1,11 @@ +"""Re-exports of authentication token helpers from ``crewai_core.auth.token``.""" + +from __future__ import annotations + +from crewai_core.auth.token import ( + AuthError as AuthError, + get_auth_token as get_auth_token, +) + + +__all__ = ["AuthError", "get_auth_token"] diff --git a/lib/crewai/src/crewai/auth/token_manager.py b/lib/crewai/src/crewai/auth/token_manager.py new file mode 100644 index 000000000..d7b31cbc8 --- /dev/null +++ b/lib/crewai/src/crewai/auth/token_manager.py @@ -0,0 +1,17 @@ +"""Deprecated: use ``crewai_core.token_manager`` instead.""" + +from __future__ import annotations + +import warnings + +from crewai_core.token_manager import TokenManager as TokenManager + + +__all__ = ["TokenManager"] + + +warnings.warn( + "crewai.auth.token_manager is deprecated; import from crewai_core.token_manager.", + DeprecationWarning, + stacklevel=2, +) diff --git a/lib/crewai/src/crewai/auth/utils.py b/lib/crewai/src/crewai/auth/utils.py new file mode 100644 index 000000000..700c5d16e --- /dev/null +++ b/lib/crewai/src/crewai/auth/utils.py @@ -0,0 +1,8 @@ +"""Re-export of ``validate_jwt_token`` from ``crewai_core.auth.utils``.""" + +from __future__ import annotations + +from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token + + +__all__ = ["validate_jwt_token"] diff --git a/lib/crewai/src/crewai/cli/__init__.py b/lib/crewai/src/crewai/cli/__init__.py index e69de29bb..24c1e866a 100644 --- a/lib/crewai/src/crewai/cli/__init__.py +++ b/lib/crewai/src/crewai/cli/__init__.py @@ -0,0 +1,74 @@ +"""Deprecated: use ``crewai_cli`` instead. + +The CLI was extracted into the standalone ``crewai-cli`` package. Legacy +``from crewai.cli.X import Y`` imports are intercepted here and resolved to +the corresponding ``crewai_cli.X`` module so downstream code keeps working. +""" + +from __future__ import annotations + +from collections.abc import Sequence +import importlib +import importlib.abc +import importlib.machinery +import sys +from types import ModuleType +import warnings + + +_PREFIX = "crewai.cli" +_TARGET = "crewai_cli" + + +warnings.warn( + "crewai.cli is deprecated; import from crewai_cli instead.", + DeprecationWarning, + stacklevel=2, +) + + +class _ShimLoader(importlib.abc.Loader): + """Returns an already-imported ``crewai_cli`` submodule without re-executing it.""" + + def __init__(self, target_name: str) -> None: + self._target_name = target_name + + def create_module(self, spec: importlib.machinery.ModuleSpec) -> ModuleType: + return importlib.import_module(self._target_name) + + def exec_module(self, module: ModuleType) -> None: + return None + + +class _ShimFinder(importlib.abc.MetaPathFinder): + """Maps ``crewai.cli[.X]`` imports onto ``crewai_cli[.X]``.""" + + def find_spec( + self, + fullname: str, + path: Sequence[str] | None, + target: ModuleType | None = None, + ) -> importlib.machinery.ModuleSpec | None: + if fullname != _PREFIX and not fullname.startswith(_PREFIX + "."): + return None + + mapped = _TARGET + fullname[len(_PREFIX) :] + try: + module = importlib.import_module(mapped) + except ImportError: + return None + + spec = importlib.machinery.ModuleSpec( + name=fullname, + loader=_ShimLoader(mapped), + origin=getattr(module, "__file__", None), + is_package=hasattr(module, "__path__"), + ) + if hasattr(module, "__path__"): + spec.submodule_search_locations = [] + return spec + + +_finder = _ShimFinder() +if _finder not in sys.meta_path: + sys.meta_path.insert(0, _finder) diff --git a/lib/crewai/src/crewai/cli/authentication/__init__.py b/lib/crewai/src/crewai/cli/authentication/__init__.py deleted file mode 100644 index 98070be42..000000000 --- a/lib/crewai/src/crewai/cli/authentication/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from crewai.cli.authentication.main import AuthenticationCommand - - -__all__ = ["AuthenticationCommand"] diff --git a/lib/crewai/src/crewai/cli/authentication/constants.py b/lib/crewai/src/crewai/cli/authentication/constants.py deleted file mode 100644 index a9457b36a..000000000 --- a/lib/crewai/src/crewai/cli/authentication/constants.py +++ /dev/null @@ -1 +0,0 @@ -ALGORITHMS = ["RS256"] diff --git a/lib/crewai/src/crewai/cli/authentication/providers/base_provider.py b/lib/crewai/src/crewai/cli/authentication/providers/base_provider.py deleted file mode 100644 index 9412ca283..000000000 --- a/lib/crewai/src/crewai/cli/authentication/providers/base_provider.py +++ /dev/null @@ -1,33 +0,0 @@ -from abc import ABC, abstractmethod - -from crewai.cli.authentication.main import Oauth2Settings - - -class BaseProvider(ABC): - def __init__(self, settings: Oauth2Settings): - self.settings = settings - - @abstractmethod - def get_authorize_url(self) -> str: ... - - @abstractmethod - def get_token_url(self) -> str: ... - - @abstractmethod - def get_jwks_url(self) -> str: ... - - @abstractmethod - def get_issuer(self) -> str: ... - - @abstractmethod - def get_audience(self) -> str: ... - - @abstractmethod - def get_client_id(self) -> str: ... - - def get_required_fields(self) -> list[str]: - """Returns which provider-specific fields inside the "extra" dict will be required""" - return [] - - def get_oauth_scopes(self) -> list[str]: - return ["openid", "profile", "email"] diff --git a/lib/crewai/src/crewai/cli/authentication/token.py b/lib/crewai/src/crewai/cli/authentication/token.py deleted file mode 100644 index 7a1d05c98..000000000 --- a/lib/crewai/src/crewai/cli/authentication/token.py +++ /dev/null @@ -1,13 +0,0 @@ -from crewai.cli.shared.token_manager import TokenManager - - -class AuthError(Exception): - pass - - -def get_auth_token() -> str: - """Get the authentication token.""" - access_token = TokenManager().get_token() - if not access_token: - raise AuthError("No token found, make sure you are logged in") - return access_token diff --git a/lib/crewai/src/crewai/constants.py b/lib/crewai/src/crewai/constants.py new file mode 100644 index 000000000..4c9db2665 --- /dev/null +++ b/lib/crewai/src/crewai/constants.py @@ -0,0 +1,352 @@ +"""CrewAI constants.""" + +from typing import Any + +from crewai_core.constants import ( + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE as CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID as CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN as CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN, + CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER as CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER, + DEFAULT_CREWAI_ENTERPRISE_URL as DEFAULT_CREWAI_ENTERPRISE_URL, +) + + +__all__ = [ + "CREWAI_ENTERPRISE_DEFAULT_OAUTH2_AUDIENCE", + "CREWAI_ENTERPRISE_DEFAULT_OAUTH2_CLIENT_ID", + "CREWAI_ENTERPRISE_DEFAULT_OAUTH2_DOMAIN", + "CREWAI_ENTERPRISE_DEFAULT_OAUTH2_PROVIDER", + "DEFAULT_CREWAI_ENTERPRISE_URL", + "DEFAULT_LLM_MODEL", + "ENV_VARS", + "JSON_URL", + "LITELLM_PARAMS", + "MODELS", + "PROVIDERS", +] + + +ENV_VARS: dict[str, list[dict[str, Any]]] = { + "openai": [ + { + "prompt": "Enter your OPENAI API key (press Enter to skip)", + "key_name": "OPENAI_API_KEY", + } + ], + "anthropic": [ + { + "prompt": "Enter your ANTHROPIC API key (press Enter to skip)", + "key_name": "ANTHROPIC_API_KEY", + } + ], + "gemini": [ + { + "prompt": "Enter your GEMINI API key from https://ai.dev/apikey (press Enter to skip)", + "key_name": "GEMINI_API_KEY", + } + ], + "nvidia_nim": [ + { + "prompt": "Enter your NVIDIA API key (press Enter to skip)", + "key_name": "NVIDIA_NIM_API_KEY", + } + ], + "groq": [ + { + "prompt": "Enter your GROQ API key (press Enter to skip)", + "key_name": "GROQ_API_KEY", + } + ], + "watson": [ + { + "prompt": "Enter your WATSONX URL (press Enter to skip)", + "key_name": "WATSONX_URL", + }, + { + "prompt": "Enter your WATSONX API Key (press Enter to skip)", + "key_name": "WATSONX_APIKEY", + }, + { + "prompt": "Enter your WATSONX Project Id (press Enter to skip)", + "key_name": "WATSONX_PROJECT_ID", + }, + ], + "ollama": [ + { + "default": True, + "API_BASE": "http://localhost:11434", + } + ], + "bedrock": [ + { + "prompt": "Enter your AWS Access Key ID (press Enter to skip)", + "key_name": "AWS_ACCESS_KEY_ID", + }, + { + "prompt": "Enter your AWS Secret Access Key (press Enter to skip)", + "key_name": "AWS_SECRET_ACCESS_KEY", + }, + { + "prompt": "Enter your AWS Region Name (press Enter to skip)", + "key_name": "AWS_DEFAULT_REGION", + }, + ], + "azure": [ + { + "prompt": "Enter your Azure deployment name (must start with 'azure/')", + "key_name": "model", + }, + { + "prompt": "Enter your AZURE API key (press Enter to skip)", + "key_name": "AZURE_API_KEY", + }, + { + "prompt": "Enter your AZURE API base URL (press Enter to skip)", + "key_name": "AZURE_API_BASE", + }, + { + "prompt": "Enter your AZURE API version (press Enter to skip)", + "key_name": "AZURE_API_VERSION", + }, + ], + "cerebras": [ + { + "prompt": "Enter your Cerebras model name (must start with 'cerebras/')", + "key_name": "model", + }, + { + "prompt": "Enter your Cerebras API version (press Enter to skip)", + "key_name": "CEREBRAS_API_KEY", + }, + ], + "huggingface": [ + { + "prompt": "Enter your Huggingface API key (HF_TOKEN) (press Enter to skip)", + "key_name": "HF_TOKEN", + }, + ], + "sambanova": [ + { + "prompt": "Enter your SambaNovaCloud API key (press Enter to skip)", + "key_name": "SAMBANOVA_API_KEY", + } + ], +} + + +PROVIDERS: list[str] = [ + "openai", + "anthropic", + "gemini", + "nvidia_nim", + "groq", + "huggingface", + "ollama", + "watson", + "bedrock", + "azure", + "cerebras", + "sambanova", +] + +MODELS: dict[str, list[str]] = { + "openai": [ + "gpt-4", + "gpt-4.1", + "gpt-4.1-mini-2025-04-14", + "gpt-4.1-nano-2025-04-14", + "gpt-4o", + "gpt-4o-mini", + "o1-mini", + "o1-preview", + ], + "anthropic": [ + "claude-3-5-sonnet-20240620", + "claude-3-sonnet-20240229", + "claude-3-opus-20240229", + "claude-3-haiku-20240307", + ], + "gemini": [ + "gemini/gemini-3-pro-preview", + "gemini/gemini-1.5-flash", + "gemini/gemini-1.5-pro", + "gemini/gemini-2.0-flash-lite-001", + "gemini/gemini-2.0-flash-001", + "gemini/gemini-2.0-flash-thinking-exp-01-21", + "gemini/gemini-2.5-flash-preview-04-17", + "gemini/gemini-2.5-pro-exp-03-25", + "gemini/gemini-gemma-2-9b-it", + "gemini/gemini-gemma-2-27b-it", + "gemini/gemma-3-1b-it", + "gemini/gemma-3-4b-it", + "gemini/gemma-3-12b-it", + "gemini/gemma-3-27b-it", + ], + "nvidia_nim": [ + "nvidia_nim/nvidia/mistral-nemo-minitron-8b-8k-instruct", + "nvidia_nim/nvidia/nemotron-4-mini-hindi-4b-instruct", + "nvidia_nim/nvidia/llama-3.1-nemotron-70b-instruct", + "nvidia_nim/nvidia/llama3-chatqa-1.5-8b", + "nvidia_nim/nvidia/llama3-chatqa-1.5-70b", + "nvidia_nim/nvidia/vila", + "nvidia_nim/nvidia/neva-22", + "nvidia_nim/nvidia/nemotron-mini-4b-instruct", + "nvidia_nim/nvidia/usdcode-llama3-70b-instruct", + "nvidia_nim/nvidia/nemotron-4-340b-instruct", + "nvidia_nim/meta/codellama-70b", + "nvidia_nim/meta/llama2-70b", + "nvidia_nim/meta/llama3-8b-instruct", + "nvidia_nim/meta/llama3-70b-instruct", + "nvidia_nim/meta/llama-3.1-8b-instruct", + "nvidia_nim/meta/llama-3.1-70b-instruct", + "nvidia_nim/meta/llama-3.1-405b-instruct", + "nvidia_nim/meta/llama-3.2-1b-instruct", + "nvidia_nim/meta/llama-3.2-3b-instruct", + "nvidia_nim/meta/llama-3.2-11b-vision-instruct", + "nvidia_nim/meta/llama-3.2-90b-vision-instruct", + "nvidia_nim/meta/llama-3.1-70b-instruct", + "nvidia_nim/google/gemma-7b", + "nvidia_nim/google/gemma-2b", + "nvidia_nim/google/codegemma-7b", + "nvidia_nim/google/codegemma-1.1-7b", + "nvidia_nim/google/recurrentgemma-2b", + "nvidia_nim/google/gemma-2-9b-it", + "nvidia_nim/google/gemma-2-27b-it", + "nvidia_nim/google/gemma-2-2b-it", + "nvidia_nim/google/deplot", + "nvidia_nim/google/paligemma", + "nvidia_nim/mistralai/mistral-7b-instruct-v0.2", + "nvidia_nim/mistralai/mixtral-8x7b-instruct-v0.1", + "nvidia_nim/mistralai/mistral-large", + "nvidia_nim/mistralai/mixtral-8x22b-instruct-v0.1", + "nvidia_nim/mistralai/mistral-7b-instruct-v0.3", + "nvidia_nim/nv-mistralai/mistral-nemo-12b-instruct", + "nvidia_nim/mistralai/mamba-codestral-7b-v0.1", + "nvidia_nim/microsoft/phi-3-mini-128k-instruct", + "nvidia_nim/microsoft/phi-3-mini-4k-instruct", + "nvidia_nim/microsoft/phi-3-small-8k-instruct", + "nvidia_nim/microsoft/phi-3-small-128k-instruct", + "nvidia_nim/microsoft/phi-3-medium-4k-instruct", + "nvidia_nim/microsoft/phi-3-medium-128k-instruct", + "nvidia_nim/microsoft/phi-3.5-mini-instruct", + "nvidia_nim/microsoft/phi-3.5-moe-instruct", + "nvidia_nim/microsoft/kosmos-2", + "nvidia_nim/microsoft/phi-3-vision-128k-instruct", + "nvidia_nim/microsoft/phi-3.5-vision-instruct", + "nvidia_nim/databricks/dbrx-instruct", + "nvidia_nim/snowflake/arctic", + "nvidia_nim/aisingapore/sea-lion-7b-instruct", + "nvidia_nim/ibm/granite-8b-code-instruct", + "nvidia_nim/ibm/granite-34b-code-instruct", + "nvidia_nim/ibm/granite-3.0-8b-instruct", + "nvidia_nim/ibm/granite-3.0-3b-a800m-instruct", + "nvidia_nim/mediatek/breeze-7b-instruct", + "nvidia_nim/upstage/solar-10.7b-instruct", + "nvidia_nim/writer/palmyra-med-70b-32k", + "nvidia_nim/writer/palmyra-med-70b", + "nvidia_nim/writer/palmyra-fin-70b-32k", + "nvidia_nim/01-ai/yi-large", + "nvidia_nim/deepseek-ai/deepseek-coder-6.7b-instruct", + "nvidia_nim/rakuten/rakutenai-7b-instruct", + "nvidia_nim/rakuten/rakutenai-7b-chat", + "nvidia_nim/baichuan-inc/baichuan2-13b-chat", + ], + "groq": [ + "groq/llama-3.1-8b-instant", + "groq/llama-3.1-70b-versatile", + "groq/llama-3.1-405b-reasoning", + "groq/gemma2-9b-it", + "groq/gemma-7b-it", + ], + "ollama": ["ollama/llama3.1", "ollama/mixtral"], + "watson": [ + "watsonx/meta-llama/llama-3-1-70b-instruct", + "watsonx/meta-llama/llama-3-1-8b-instruct", + "watsonx/meta-llama/llama-3-2-11b-vision-instruct", + "watsonx/meta-llama/llama-3-2-1b-instruct", + "watsonx/meta-llama/llama-3-2-90b-vision-instruct", + "watsonx/meta-llama/llama-3-405b-instruct", + "watsonx/mistral/mistral-large", + "watsonx/ibm/granite-3-8b-instruct", + ], + "bedrock": [ + "bedrock/us.amazon.nova-pro-v1:0", + "bedrock/us.amazon.nova-micro-v1:0", + "bedrock/us.amazon.nova-lite-v1:0", + "bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0", + "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0", + "bedrock/us.anthropic.claude-3-5-sonnet-20241022-v2:0", + "bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "bedrock/us.anthropic.claude-3-sonnet-20240229-v1:0", + "bedrock/us.anthropic.claude-3-opus-20240229-v1:0", + "bedrock/us.anthropic.claude-3-haiku-20240307-v1:0", + "bedrock/us.meta.llama3-2-11b-instruct-v1:0", + "bedrock/us.meta.llama3-2-3b-instruct-v1:0", + "bedrock/us.meta.llama3-2-90b-instruct-v1:0", + "bedrock/us.meta.llama3-2-1b-instruct-v1:0", + "bedrock/us.meta.llama3-1-8b-instruct-v1:0", + "bedrock/us.meta.llama3-1-70b-instruct-v1:0", + "bedrock/us.meta.llama3-3-70b-instruct-v1:0", + "bedrock/us.meta.llama3-1-405b-instruct-v1:0", + "bedrock/eu.anthropic.claude-3-5-sonnet-20240620-v1:0", + "bedrock/eu.anthropic.claude-3-sonnet-20240229-v1:0", + "bedrock/eu.anthropic.claude-3-haiku-20240307-v1:0", + "bedrock/eu.meta.llama3-2-3b-instruct-v1:0", + "bedrock/eu.meta.llama3-2-1b-instruct-v1:0", + "bedrock/apac.anthropic.claude-3-5-sonnet-20240620-v1:0", + "bedrock/apac.anthropic.claude-3-5-sonnet-20241022-v2:0", + "bedrock/apac.anthropic.claude-3-sonnet-20240229-v1:0", + "bedrock/apac.anthropic.claude-3-haiku-20240307-v1:0", + "bedrock/amazon.nova-pro-v1:0", + "bedrock/amazon.nova-micro-v1:0", + "bedrock/amazon.nova-lite-v1:0", + "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + "bedrock/anthropic.claude-3-5-haiku-20241022-v1:0", + "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0", + "bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0", + "bedrock/anthropic.claude-3-sonnet-20240229-v1:0", + "bedrock/anthropic.claude-3-opus-20240229-v1:0", + "bedrock/anthropic.claude-3-haiku-20240307-v1:0", + "bedrock/anthropic.claude-v2:1", + "bedrock/anthropic.claude-v2", + "bedrock/anthropic.claude-instant-v1", + "bedrock/meta.llama3-1-405b-instruct-v1:0", + "bedrock/meta.llama3-1-70b-instruct-v1:0", + "bedrock/meta.llama3-1-8b-instruct-v1:0", + "bedrock/meta.llama3-70b-instruct-v1:0", + "bedrock/meta.llama3-8b-instruct-v1:0", + "bedrock/amazon.titan-text-lite-v1", + "bedrock/amazon.titan-text-express-v1", + "bedrock/cohere.command-text-v14", + "bedrock/ai21.j2-mid-v1", + "bedrock/ai21.j2-ultra-v1", + "bedrock/ai21.jamba-instruct-v1:0", + "bedrock/mistral.mistral-7b-instruct-v0:2", + "bedrock/mistral.mixtral-8x7b-instruct-v0:1", + ], + "huggingface": [ + "huggingface/meta-llama/Meta-Llama-3.1-8B-Instruct", + "huggingface/mistralai/Mixtral-8x7B-Instruct-v0.1", + "huggingface/tiiuae/falcon-180B-chat", + "huggingface/google/gemma-7b-it", + ], + "sambanova": [ + "sambanova/Meta-Llama-3.3-70B-Instruct", + "sambanova/QwQ-32B-Preview", + "sambanova/Qwen2.5-72B-Instruct", + "sambanova/Qwen2.5-Coder-32B-Instruct", + "sambanova/Meta-Llama-3.1-405B-Instruct", + "sambanova/Meta-Llama-3.1-70B-Instruct", + "sambanova/Meta-Llama-3.1-8B-Instruct", + "sambanova/Llama-3.2-90B-Vision-Instruct", + "sambanova/Llama-3.2-11B-Vision-Instruct", + "sambanova/Meta-Llama-3.2-3B-Instruct", + "sambanova/Meta-Llama-3.2-1B-Instruct", + ], +} + +DEFAULT_LLM_MODEL = "gpt-4.1-mini" + +JSON_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" + +LITELLM_PARAMS = ["api_key", "api_base", "api_version"] diff --git a/lib/crewai/src/crewai/crew.py b/lib/crewai/src/crewai/crew.py index f7321b6ae..60f163155 100644 --- a/lib/crewai/src/crewai/crew.py +++ b/lib/crewai/src/crewai/crew.py @@ -54,6 +54,8 @@ except ImportError: return [] +from crewai_core.printer import PrinterColor + from crewai.agent import Agent from crewai.agents.agent_builder.base_agent import ( BaseAgent, @@ -132,7 +134,6 @@ from crewai.utilities.i18n import get_i18n from crewai.utilities.llm_utils import create_llm from crewai.utilities.logger import Logger from crewai.utilities.planning_handler import CrewPlanner -from crewai.utilities.printer import PrinterColor from crewai.utilities.rpm_controller import RPMController from crewai.utilities.streaming import ( create_async_chunk_generator, diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py index e35fe66e1..72dbb21a2 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py @@ -6,20 +6,20 @@ import time from typing import Any import uuid +from crewai_core.settings import Settings from rich.console import Console from rich.panel import Panel -from crewai.cli.authentication.token import AuthError, get_auth_token -from crewai.cli.config import Settings -from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL -from crewai.cli.plus_api import PlusAPI +from crewai.auth.token import AuthError, get_auth_token +from crewai.constants import DEFAULT_CREWAI_ENTERPRISE_URL from crewai.events.listeners.tracing.types import TraceEvent from crewai.events.listeners.tracing.utils import ( get_user_id, is_tracing_enabled_in_context, should_auto_collect_first_time_traces, ) -from crewai.utilities.version import get_crewai_version +from crewai.plus_api import PlusAPI +from crewai.version import get_crewai_version logger = getLogger(__name__) diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py index f494877be..8bac1518e 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py @@ -6,7 +6,7 @@ import uuid from typing_extensions import Self -from crewai.cli.authentication.token import AuthError, get_auth_token +from crewai.auth.token import AuthError, get_auth_token from crewai.events.base_event_listener import BaseEventListener from crewai.events.base_events import BaseEvent from crewai.events.event_bus import CrewAIEventsBus @@ -127,7 +127,7 @@ from crewai.events.types.tool_usage_events import ( ToolUsageStartedEvent, ) from crewai.events.utils.console_formatter import ConsoleFormatter -from crewai.utilities.version import get_crewai_version +from crewai.version import get_crewai_version class TraceCollectionListener(BaseEventListener): diff --git a/lib/crewai/src/crewai/events/listeners/tracing/utils.py b/lib/crewai/src/crewai/events/listeners/tracing/utils.py index 314922870..b02ab6d4e 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/utils.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/utils.py @@ -15,15 +15,49 @@ from typing import Any, cast import uuid import click +from crewai_core.lock_store import lock as store_lock +from crewai_core.user_data import ( + _load_user_data as _load_user_data, + _save_user_data as _save_user_data, + _user_data_file as _user_data_file, + _user_data_lock_name as _user_data_lock_name, + has_user_declined_tracing as has_user_declined_tracing, + is_tracing_enabled as is_tracing_enabled, + update_user_data as update_user_data, +) from rich.console import Console from rich.panel import Panel from rich.text import Text -from crewai.utilities.lock_store import lock as store_lock -from crewai.utilities.paths import db_storage_path from crewai.utilities.serialization import to_serializable +__all__ = [ + "_load_user_data", + "_save_user_data", + "_user_data_file", + "_user_data_lock_name", + "get_user_id", + "has_user_declined_tracing", + "is_first_execution", + "is_tracing_enabled", + "is_tracing_enabled_in_context", + "mark_first_execution_completed", + "mark_first_execution_done", + "on_first_execution_tracing_confirmation", + "prompt_user_for_trace_viewing", + "reset_tracing_enabled", + "safe_serialize_to_dict", + "set_suppress_tracing_messages", + "set_tracing_enabled", + "should_auto_collect_first_time_traces", + "should_enable_tracing", + "should_suppress_tracing_messages", + "truncate_messages", + "update_user_data", +] + + logger = logging.getLogger(__name__) @@ -123,69 +157,6 @@ def is_tracing_enabled_in_context() -> bool: return enabled if enabled is not None else False -def _user_data_file() -> Path: - base = Path(db_storage_path()) - base.mkdir(parents=True, exist_ok=True) - return base / ".crewai_user.json" - - -def _load_user_data() -> dict[str, Any]: - p = _user_data_file() - if p.exists(): - try: - return cast(dict[str, Any], json.loads(p.read_text())) - except (json.JSONDecodeError, OSError, PermissionError) as e: - logger.warning(f"Failed to load user data: {e}") - return {} - - -def _user_data_lock_name() -> str: - """Return a stable lock name for the user data file.""" - return f"file:{os.path.realpath(_user_data_file())}" - - -def update_user_data(updates: dict[str, Any]) -> None: - """Atomically read-modify-write the user data file. - - Args: - updates: Key-value pairs to merge into the existing user data. - """ - try: - with store_lock(_user_data_lock_name()): - data = _load_user_data() - data.update(updates) - p = _user_data_file() - p.write_text(json.dumps(data, indent=2)) - except (OSError, PermissionError) as e: - logger.warning(f"Failed to update user data: {e}") - - -def has_user_declined_tracing() -> bool: - """Check if user has explicitly declined trace collection. - - Returns: - True if user previously declined tracing, False otherwise. - """ - data = _load_user_data() - if data.get("first_execution_done", False): - return data.get("trace_consent", False) is False - return False - - -def is_tracing_enabled() -> bool: - """Check if tracing should be enabled. - - - Returns: - True if tracing is enabled and not disabled, False otherwise. - """ - # If user has explicitly declined tracing, never enable it - if has_user_declined_tracing(): - return False - - return os.getenv("CREWAI_TRACING_ENABLED", "false").lower() == "true" - - def on_first_execution_tracing_confirmation() -> bool: if _is_test_environment(): return False diff --git a/lib/crewai/src/crewai/events/utils/console_formatter.py b/lib/crewai/src/crewai/events/utils/console_formatter.py index 7879a4d93..203468db5 100644 --- a/lib/crewai/src/crewai/events/utils/console_formatter.py +++ b/lib/crewai/src/crewai/events/utils/console_formatter.py @@ -3,43 +3,29 @@ import os import threading from typing import Any, ClassVar, cast +from crewai_core.printer import ( + set_suppress_console_output as set_suppress_console_output, + should_suppress_console_output as should_suppress_console_output, +) from rich.console import Console from rich.live import Live from rich.panel import Panel from rich.text import Text -from crewai.cli.version import is_current_version_yanked, is_newer_version_available +from crewai.version import is_current_version_yanked, is_newer_version_available + + +__all__ = [ + "ConsoleFormatter", + "set_suppress_console_output", + "should_suppress_console_output", +] _disable_version_check: ContextVar[bool] = ContextVar( "_disable_version_check", default=False ) -_suppress_console_output: ContextVar[bool] = ContextVar( - "_suppress_console_output", default=False -) - - -def set_suppress_console_output(suppress: bool) -> object: - """Set whether to suppress all console output. - - Args: - suppress: True to suppress output, False to show it. - - Returns: - A token that can be used to restore the previous value. - """ - return _suppress_console_output.set(suppress) - - -def should_suppress_console_output() -> bool: - """Check if console output should be suppressed. - - Returns: - True if output should be suppressed, False otherwise. - """ - return _suppress_console_output.get() - class ConsoleFormatter: tool_usage_counts: ClassVar[dict[str, int]] = {} diff --git a/lib/crewai/src/crewai/experimental/agent_executor.py b/lib/crewai/src/crewai/experimental/agent_executor.py index de6f2ad62..a650d917c 100644 --- a/lib/crewai/src/crewai/experimental/agent_executor.py +++ b/lib/crewai/src/crewai/experimental/agent_executor.py @@ -12,6 +12,7 @@ import threading from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast from uuid import uuid4 +from crewai_core.printer import PRINTER from pydantic import ( BaseModel, Field, @@ -99,7 +100,6 @@ from crewai.utilities.planning_types import ( TodoItem, TodoList, ) -from crewai.utilities.printer import PRINTER from crewai.utilities.step_execution_context import StepExecutionContext, StepResult from crewai.utilities.string_utils import sanitize_tool_name from crewai.utilities.tool_utils import execute_tool_and_check_finality diff --git a/lib/crewai/src/crewai/flow/persistence/decorators.py b/lib/crewai/src/crewai/flow/persistence/decorators.py index 937b557f4..f7881fdc3 100644 --- a/lib/crewai/src/crewai/flow/persistence/decorators.py +++ b/lib/crewai/src/crewai/flow/persistence/decorators.py @@ -30,11 +30,11 @@ import functools import logging from typing import TYPE_CHECKING, Any, Final, TypeVar, cast +from crewai_core.printer import PRINTER from pydantic import BaseModel from crewai.flow.persistence.base import FlowPersistence from crewai.flow.persistence.sqlite import SQLiteFlowPersistence -from crewai.utilities.printer import PRINTER if TYPE_CHECKING: diff --git a/lib/crewai/src/crewai/flow/persistence/sqlite.py b/lib/crewai/src/crewai/flow/persistence/sqlite.py index fa2e4e127..77289ab2f 100644 --- a/lib/crewai/src/crewai/flow/persistence/sqlite.py +++ b/lib/crewai/src/crewai/flow/persistence/sqlite.py @@ -9,12 +9,12 @@ from pathlib import Path import sqlite3 from typing import TYPE_CHECKING, Any +from crewai_core.lock_store import lock as store_lock +from crewai_core.paths import db_storage_path from pydantic import BaseModel, Field, PrivateAttr, model_validator from typing_extensions import Self from crewai.flow.persistence.base import FlowPersistence -from crewai.utilities.lock_store import lock as store_lock -from crewai.utilities.paths import db_storage_path if TYPE_CHECKING: diff --git a/lib/crewai/src/crewai/flow/utils.py b/lib/crewai/src/crewai/flow/utils.py index 652a38f4c..917ed40b9 100644 --- a/lib/crewai/src/crewai/flow/utils.py +++ b/lib/crewai/src/crewai/flow/utils.py @@ -22,6 +22,7 @@ import inspect import textwrap from typing import TYPE_CHECKING, Any +from crewai_core.printer import PRINTER from typing_extensions import TypeIs from crewai.flow.constants import AND_CONDITION, OR_CONDITION @@ -32,7 +33,6 @@ from crewai.flow.flow_wrappers import ( SimpleFlowCondition, ) from crewai.flow.types import FlowMethodCallable, FlowMethodName -from crewai.utilities.printer import PRINTER if TYPE_CHECKING: diff --git a/lib/crewai/src/crewai/hooks/llm_hooks.py b/lib/crewai/src/crewai/hooks/llm_hooks.py index bc3d1d17d..f64605c8e 100644 --- a/lib/crewai/src/crewai/hooks/llm_hooks.py +++ b/lib/crewai/src/crewai/hooks/llm_hooks.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, cast +from crewai_core.printer import PRINTER + from crewai.events.event_listener import event_listener from crewai.hooks.types import ( AfterLLMCallHookCallable, @@ -9,7 +11,6 @@ from crewai.hooks.types import ( BeforeLLMCallHookCallable, BeforeLLMCallHookType, ) -from crewai.utilities.printer import PRINTER if TYPE_CHECKING: diff --git a/lib/crewai/src/crewai/hooks/tool_hooks.py b/lib/crewai/src/crewai/hooks/tool_hooks.py index 6d9c015b5..70edf03fb 100644 --- a/lib/crewai/src/crewai/hooks/tool_hooks.py +++ b/lib/crewai/src/crewai/hooks/tool_hooks.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +from crewai_core.printer import PRINTER + from crewai.events.event_listener import event_listener from crewai.hooks.types import ( AfterToolCallHookCallable, @@ -9,7 +11,6 @@ from crewai.hooks.types import ( BeforeToolCallHookCallable, BeforeToolCallHookType, ) -from crewai.utilities.printer import PRINTER if TYPE_CHECKING: diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index fbc9cf0b5..cd9823e15 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -35,6 +35,8 @@ if TYPE_CHECKING: from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig +from crewai_core.printer import PRINTER + from crewai.agents.agent_builder.base_agent import BaseAgent from crewai.agents.agent_builder.utilities.base_token_process import TokenProcess from crewai.agents.cache.cache_handler import CacheHandler @@ -92,7 +94,6 @@ from crewai.utilities.guardrail import process_guardrail, serialize_guardrail_fo from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType from crewai.utilities.i18n import I18N_DEFAULT from crewai.utilities.llm_utils import create_llm -from crewai.utilities.printer import PRINTER from crewai.utilities.pydantic_schema_utils import generate_model_description from crewai.utilities.token_counter_callback import TokenCalcHandler from crewai.utilities.tool_utils import execute_tool_and_check_finality diff --git a/lib/crewai/src/crewai/llms/base_llm.py b/lib/crewai/src/crewai/llms/base_llm.py index a9aa7dc01..86a3ba276 100644 --- a/lib/crewai/src/crewai/llms/base_llm.py +++ b/lib/crewai/src/crewai/llms/base_llm.py @@ -900,11 +900,12 @@ class BaseLLM(BaseModel, ABC): if from_agent is not None: return True + from crewai_core.printer import PRINTER + from crewai.hooks.llm_hooks import ( LLMCallHookContext, get_before_llm_call_hooks, ) - from crewai.utilities.printer import PRINTER before_hooks = get_before_llm_call_hooks() if not before_hooks: @@ -969,11 +970,12 @@ class BaseLLM(BaseModel, ABC): if from_agent is not None or not isinstance(response, str): return response + from crewai_core.printer import PRINTER + from crewai.hooks.llm_hooks import ( LLMCallHookContext, get_after_llm_call_hooks, ) - from crewai.utilities.printer import PRINTER after_hooks = get_after_llm_call_hooks() if not after_hooks: diff --git a/lib/crewai/src/crewai/mcp/__init__.py b/lib/crewai/src/crewai/mcp/__init__.py index bb3dab199..ee057af14 100644 --- a/lib/crewai/src/crewai/mcp/__init__.py +++ b/lib/crewai/src/crewai/mcp/__init__.py @@ -27,6 +27,7 @@ from crewai.mcp.filters import ( create_static_tool_filter, ) + if TYPE_CHECKING: from crewai.mcp.client import MCPClient from crewai.mcp.tool_resolver import MCPToolResolver diff --git a/lib/crewai/src/crewai/mcp/tool_resolver.py b/lib/crewai/src/crewai/mcp/tool_resolver.py index e4cad9a7f..cbfd3e40a 100644 --- a/lib/crewai/src/crewai/mcp/tool_resolver.py +++ b/lib/crewai/src/crewai/mcp/tool_resolver.py @@ -23,7 +23,6 @@ from crewai.mcp.config import ( MCPServerSSE, MCPServerStdio, ) -from crewai.mcp.transports.base import BaseTransport from crewai.mcp.transports.http import HTTPTransport from crewai.mcp.transports.sse import SSETransport from crewai.mcp.transports.stdio import StdioTransport @@ -196,7 +195,7 @@ class MCPToolResolver: get_platform_integration_token, ) - from crewai.cli.plus_api import PlusAPI + from crewai.plus_api import PlusAPI plus_api = PlusAPI(api_key=get_platform_integration_token()) response = plus_api.get_mcp_configs(slugs) @@ -286,7 +285,7 @@ class MCPToolResolver: independent transport so that parallel tool executions never share state. """ - transport: BaseTransport + transport: StdioTransport | HTTPTransport | SSETransport if isinstance(mcp_config, MCPServerStdio): transport = StdioTransport( command=mcp_config.command, diff --git a/lib/crewai/src/crewai/memory/storage/kickoff_task_outputs_storage.py b/lib/crewai/src/crewai/memory/storage/kickoff_task_outputs_storage.py index 3f5f38c9f..2a9ab2e29 100644 --- a/lib/crewai/src/crewai/memory/storage/kickoff_task_outputs_storage.py +++ b/lib/crewai/src/crewai/memory/storage/kickoff_task_outputs_storage.py @@ -5,11 +5,12 @@ from pathlib import Path import sqlite3 from typing import Any +from crewai_core.lock_store import lock as store_lock +from crewai_core.paths import db_storage_path + from crewai.task import Task from crewai.utilities.crew_json_encoder import CrewJSONEncoder from crewai.utilities.errors import DatabaseError, DatabaseOperationError -from crewai.utilities.lock_store import lock as store_lock -from crewai.utilities.paths import db_storage_path logger = logging.getLogger(__name__) diff --git a/lib/crewai/src/crewai/memory/storage/lancedb_storage.py b/lib/crewai/src/crewai/memory/storage/lancedb_storage.py index a7a2d3956..25793468b 100644 --- a/lib/crewai/src/crewai/memory/storage/lancedb_storage.py +++ b/lib/crewai/src/crewai/memory/storage/lancedb_storage.py @@ -12,10 +12,10 @@ import threading import time from typing import Any +from crewai_core.lock_store import lock as store_lock import lancedb # type: ignore[import-untyped] from crewai.memory.types import MemoryRecord, ScopeInfo -from crewai.utilities.lock_store import lock as store_lock _logger = logging.getLogger(__name__) @@ -68,7 +68,7 @@ class LanceDBStorage: if storage_dir: path = Path(storage_dir) / "memory" else: - from crewai.utilities.paths import db_storage_path + from crewai_core.paths import db_storage_path path = Path(db_storage_path()) / "memory" self._path = Path(path) diff --git a/lib/crewai/src/crewai/memory/storage/qdrant_edge_storage.py b/lib/crewai/src/crewai/memory/storage/qdrant_edge_storage.py index f20faa408..d819094e9 100644 --- a/lib/crewai/src/crewai/memory/storage/qdrant_edge_storage.py +++ b/lib/crewai/src/crewai/memory/storage/qdrant_edge_storage.py @@ -104,7 +104,7 @@ class QdrantEdgeStorage: if storage_dir: path = Path(storage_dir) / "memory" / "qdrant-edge" else: - from crewai.utilities.paths import db_storage_path + from crewai_core.paths import db_storage_path path = Path(db_storage_path()) / "memory" / "qdrant-edge" diff --git a/lib/crewai/src/crewai/plus_api.py b/lib/crewai/src/crewai/plus_api.py new file mode 100644 index 000000000..e8e1722e7 --- /dev/null +++ b/lib/crewai/src/crewai/plus_api.py @@ -0,0 +1,12 @@ +"""Re-export of ``crewai_core.plus_api.PlusAPI``. + +Kept as a stable import path for the framework; new code should import from +``crewai_core.plus_api`` directly. +""" + +from __future__ import annotations + +from crewai_core.plus_api import PlusAPI as PlusAPI + + +__all__ = ["PlusAPI"] diff --git a/lib/crewai/src/crewai/rag/chromadb/client.py b/lib/crewai/src/crewai/rag/chromadb/client.py index 02f28c7f6..be52a4e17 100644 --- a/lib/crewai/src/crewai/rag/chromadb/client.py +++ b/lib/crewai/src/crewai/rag/chromadb/client.py @@ -10,6 +10,7 @@ from chromadb.api.types import ( EmbeddingFunction as ChromaEmbeddingFunction, QueryResult, ) +from crewai_core.lock_store import lock as store_lock from typing_extensions import Unpack from crewai.rag.chromadb.types import ( @@ -32,7 +33,6 @@ from crewai.rag.core.base_client import ( BaseCollectionParams, ) from crewai.rag.types import SearchResult -from crewai.utilities.lock_store import lock as store_lock from crewai.utilities.logger_utils import suppress_logging diff --git a/lib/crewai/src/crewai/rag/chromadb/constants.py b/lib/crewai/src/crewai/rag/chromadb/constants.py index 73b659fdf..bdeb7ed3a 100644 --- a/lib/crewai/src/crewai/rag/chromadb/constants.py +++ b/lib/crewai/src/crewai/rag/chromadb/constants.py @@ -3,7 +3,7 @@ import re from typing import Final -from crewai.utilities.paths import db_storage_path +from crewai_core.paths import db_storage_path DEFAULT_TENANT: Final[str] = "default_tenant" diff --git a/lib/crewai/src/crewai/rag/chromadb/factory.py b/lib/crewai/src/crewai/rag/chromadb/factory.py index f48425ab3..5e95bf9e8 100644 --- a/lib/crewai/src/crewai/rag/chromadb/factory.py +++ b/lib/crewai/src/crewai/rag/chromadb/factory.py @@ -3,10 +3,10 @@ import os from chromadb import PersistentClient +from crewai_core.lock_store import lock from crewai.rag.chromadb.client import ChromaDBClient from crewai.rag.chromadb.config import ChromaDBConfig -from crewai.utilities.lock_store import lock def create_client(config: ChromaDBConfig) -> ChromaDBClient: diff --git a/lib/crewai/src/crewai/rag/embeddings/providers/ibm/embedding_callable.py b/lib/crewai/src/crewai/rag/embeddings/providers/ibm/embedding_callable.py index 44e97149a..237ff4a5c 100644 --- a/lib/crewai/src/crewai/rag/embeddings/providers/ibm/embedding_callable.py +++ b/lib/crewai/src/crewai/rag/embeddings/providers/ibm/embedding_callable.py @@ -3,10 +3,10 @@ from typing import Any, cast from chromadb.api.types import Documents, EmbeddingFunction, Embeddings +from crewai_core.printer import PRINTER from typing_extensions import Unpack from crewai.rag.embeddings.providers.ibm.types import WatsonXProviderConfig -from crewai.utilities.printer import PRINTER class WatsonXEmbeddingFunction(EmbeddingFunction[Documents]): diff --git a/lib/crewai/src/crewai/rag/qdrant/constants.py b/lib/crewai/src/crewai/rag/qdrant/constants.py index 75e8e7c25..750cbc139 100644 --- a/lib/crewai/src/crewai/rag/qdrant/constants.py +++ b/lib/crewai/src/crewai/rag/qdrant/constants.py @@ -3,10 +3,9 @@ import os from typing import Final +from crewai_core.paths import db_storage_path from qdrant_client.models import Distance, VectorParams -from crewai.utilities.paths import db_storage_path - DEFAULT_VECTOR_PARAMS: Final = VectorParams(size=384, distance=Distance.COSINE) DEFAULT_EMBEDDING_MODEL: Final[str] = "sentence-transformers/all-MiniLM-L6-v2" diff --git a/lib/crewai/src/crewai/settings.py b/lib/crewai/src/crewai/settings.py new file mode 100644 index 000000000..e9d41243e --- /dev/null +++ b/lib/crewai/src/crewai/settings.py @@ -0,0 +1,30 @@ +"""Re-exports of shared settings from ``crewai_core.settings``. + +Existing imports from ``crewai.settings`` continue to work; new code should +import from ``crewai_core.settings`` directly. +""" + +from __future__ import annotations + +from crewai_core.settings import ( + CLI_SETTINGS_KEYS as CLI_SETTINGS_KEYS, + DEFAULT_CLI_SETTINGS as DEFAULT_CLI_SETTINGS, + DEFAULT_CONFIG_PATH as DEFAULT_CONFIG_PATH, + HIDDEN_SETTINGS_KEYS as HIDDEN_SETTINGS_KEYS, + READONLY_SETTINGS_KEYS as READONLY_SETTINGS_KEYS, + USER_SETTINGS_KEYS as USER_SETTINGS_KEYS, + Settings as Settings, + get_writable_config_path as get_writable_config_path, +) + + +__all__ = [ + "CLI_SETTINGS_KEYS", + "DEFAULT_CLI_SETTINGS", + "DEFAULT_CONFIG_PATH", + "HIDDEN_SETTINGS_KEYS", + "READONLY_SETTINGS_KEYS", + "USER_SETTINGS_KEYS", + "Settings", + "get_writable_config_path", +] diff --git a/lib/crewai/src/crewai/state/runtime.py b/lib/crewai/src/crewai/state/runtime.py index 471107997..2662266d2 100644 --- a/lib/crewai/src/crewai/state/runtime.py +++ b/lib/crewai/src/crewai/state/runtime.py @@ -14,6 +14,7 @@ import time from typing import TYPE_CHECKING, Any import uuid +from crewai_core.version import get_crewai_version from packaging.version import Version from pydantic import ( ModelWrapValidatorHandler, @@ -39,7 +40,6 @@ from crewai.state.checkpoint_config import CheckpointConfig from crewai.state.event_record import EventRecord from crewai.state.provider.core import BaseProvider from crewai.state.provider.json_provider import JsonProvider -from crewai.utilities.version import get_crewai_version logger = logging.getLogger(__name__) diff --git a/lib/crewai/src/crewai/task.py b/lib/crewai/src/crewai/task.py index 28136097f..b8b726b77 100644 --- a/lib/crewai/src/crewai/task.py +++ b/lib/crewai/src/crewai/task.py @@ -77,6 +77,8 @@ except ImportError: return [] +from crewai_core.printer import PRINTER + from crewai.types.callback import SerializableCallable from crewai.utilities.guardrail import ( process_guardrail, @@ -89,7 +91,6 @@ from crewai.utilities.guardrail_types import ( GuardrailsType, ) from crewai.utilities.i18n import I18N_DEFAULT -from crewai.utilities.printer import PRINTER from crewai.utilities.string_utils import interpolate_only diff --git a/lib/crewai/src/crewai/tools/tool_usage.py b/lib/crewai/src/crewai/tools/tool_usage.py index 09b44be17..0a004059a 100644 --- a/lib/crewai/src/crewai/tools/tool_usage.py +++ b/lib/crewai/src/crewai/tools/tool_usage.py @@ -9,6 +9,7 @@ from textwrap import dedent import time from typing import TYPE_CHECKING, Any, Literal +from crewai_core.printer import PRINTER import json5 from json_repair import repair_json # type: ignore[import-untyped] @@ -29,7 +30,6 @@ from crewai.utilities.agent_utils import ( ) from crewai.utilities.converter import Converter from crewai.utilities.i18n import I18N_DEFAULT -from crewai.utilities.printer import PRINTER from crewai.utilities.string_utils import sanitize_tool_name diff --git a/lib/crewai/src/crewai/utilities/__init__.py b/lib/crewai/src/crewai/utilities/__init__.py index b2c02dce0..9910d6ba0 100644 --- a/lib/crewai/src/crewai/utilities/__init__.py +++ b/lib/crewai/src/crewai/utilities/__init__.py @@ -1,3 +1,5 @@ +from crewai_core.printer import Printer + from crewai.utilities.converter import Converter, ConverterError from crewai.utilities.exceptions.context_window_exceeding_exception import ( LLMContextLengthExceededError, @@ -6,7 +8,6 @@ from crewai.utilities.file_handler import FileHandler from crewai.utilities.i18n import I18N from crewai.utilities.internal_instructor import InternalInstructor from crewai.utilities.logger import Logger -from crewai.utilities.printer import Printer from crewai.utilities.prompts import Prompts from crewai.utilities.rpm_controller import RPMController diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 91b98a12c..3cb72331c 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -12,6 +12,8 @@ import json import re from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict +from crewai_core.printer import PRINTER, ColoredText, Printer +from crewai_core.settings import Settings from pydantic import BaseModel from rich.console import Console @@ -22,7 +24,6 @@ from crewai.agents.parser import ( OutputParserError, parse, ) -from crewai.cli.config import Settings from crewai.llms.base_llm import BaseLLM, call_stop_override from crewai.tools import BaseTool as CrewAITool from crewai.tools.base_tool import BaseTool @@ -33,7 +34,6 @@ from crewai.utilities.exceptions.context_window_exceeding_exception import ( LLMContextLengthExceededError, ) from crewai.utilities.i18n import I18N_DEFAULT -from crewai.utilities.printer import PRINTER, ColoredText, Printer from crewai.utilities.pydantic_schema_utils import generate_model_description from crewai.utilities.string_utils import sanitize_tool_name from crewai.utilities.token_counter_callback import TokenCalcHandler @@ -1131,8 +1131,8 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]: if callable(_create_plus_client_hook): client = _create_plus_client_hook() else: - from crewai.cli.authentication.token import get_auth_token - from crewai.cli.plus_api import PlusAPI + from crewai.auth.token import get_auth_token + from crewai.plus_api import PlusAPI client = PlusAPI(api_key=get_auth_token()) _print_current_organization() diff --git a/lib/crewai/src/crewai/utilities/constants.py b/lib/crewai/src/crewai/utilities/constants.py index 1f80dcbe6..918043951 100644 --- a/lib/crewai/src/crewai/utilities/constants.py +++ b/lib/crewai/src/crewai/utilities/constants.py @@ -1,15 +1,30 @@ from typing import Annotated, Final +from crewai_core.constants import ( + CREWAI_TRAINED_AGENTS_FILE_ENV as CREWAI_TRAINED_AGENTS_FILE_ENV, + KNOWLEDGE_DIRECTORY as KNOWLEDGE_DIRECTORY, + MAX_FILE_NAME_LENGTH as MAX_FILE_NAME_LENGTH, + TRAINED_AGENTS_DATA_FILE as TRAINED_AGENTS_DATA_FILE, + TRAINING_DATA_FILE as TRAINING_DATA_FILE, +) +from crewai_core.printer import PrinterColor from pydantic_core import CoreSchema -from crewai.utilities.printer import PrinterColor + +__all__ = [ + "CC_ENV_VAR", + "CODEX_ENV_VARS", + "CREWAI_TRAINED_AGENTS_FILE_ENV", + "CURSOR_ENV_VARS", + "EMITTER_COLOR", + "KNOWLEDGE_DIRECTORY", + "MAX_FILE_NAME_LENGTH", + "NOT_SPECIFIED", + "TRAINED_AGENTS_DATA_FILE", + "TRAINING_DATA_FILE", +] -TRAINING_DATA_FILE: Final[str] = "training_data.pkl" -TRAINED_AGENTS_DATA_FILE: Final[str] = "trained_agents_data.pkl" -CREWAI_TRAINED_AGENTS_FILE_ENV: Final[str] = "CREWAI_TRAINED_AGENTS_FILE" -KNOWLEDGE_DIRECTORY: Final[str] = "knowledge" -MAX_FILE_NAME_LENGTH: Final[int] = 255 EMITTER_COLOR: Final[PrinterColor] = "bold_blue" CC_ENV_VAR: Final[str] = "CLAUDECODE" CODEX_ENV_VARS: Final[tuple[str, ...]] = ( diff --git a/lib/crewai/src/crewai/utilities/converter.py b/lib/crewai/src/crewai/utilities/converter.py index e8a73f192..d31b76f48 100644 --- a/lib/crewai/src/crewai/utilities/converter.py +++ b/lib/crewai/src/crewai/utilities/converter.py @@ -5,13 +5,13 @@ import json import re from typing import TYPE_CHECKING, Any, Final, TypedDict +from crewai_core.printer import PRINTER from pydantic import BaseModel, ValidationError from typing_extensions import Unpack from crewai.agents.agent_builder.utilities.base_output_converter import OutputConverter from crewai.utilities.i18n import I18N_DEFAULT from crewai.utilities.internal_instructor import InternalInstructor -from crewai.utilities.printer import PRINTER from crewai.utilities.pydantic_schema_utils import generate_model_description diff --git a/lib/crewai/src/crewai/cli/crew_chat.py b/lib/crewai/src/crewai/utilities/crew_chat.py similarity index 81% rename from lib/crewai/src/crewai/cli/crew_chat.py rename to lib/crewai/src/crewai/utilities/crew_chat.py index c5e170eb1..0ae40ea5b 100644 --- a/lib/crewai/src/crewai/cli/crew_chat.py +++ b/lib/crewai/src/crewai/utilities/crew_chat.py @@ -1,3 +1,5 @@ +"""Interactive chat interface for CrewAI crews.""" + import contextvars import json from pathlib import Path @@ -9,18 +11,18 @@ import time from typing import Any, Final, Literal import click +from crewai_core.printer import PRINTER from packaging import version import tomli -from crewai.cli.utils import read_toml from crewai.crew import Crew from crewai.llm import LLM from crewai.llms.base_llm import BaseLLM from crewai.types.crew_chat import ChatInputField, ChatInputs from crewai.utilities.llm_utils import create_llm -from crewai.utilities.printer import PRINTER +from crewai.utilities.project_utils import read_toml from crewai.utilities.types import LLMMessage -from crewai.utilities.version import get_crewai_version +from crewai.version import get_crewai_version MIN_REQUIRED_VERSION: Final[Literal["0.98.0"]] = "0.98.0" @@ -32,15 +34,14 @@ DEFAULT_CREW_DESCRIPTION: Final[str] = "A CrewAI crew." def check_conversational_crews_version( crewai_version: str, pyproject_data: dict[str, Any] ) -> bool: - """ - Check if the installed crewAI version supports conversational crews. + """Check if the installed crewAI version supports conversational crews. Args: crewai_version: The current version of crewAI. pyproject_data: Dictionary containing pyproject.toml data. Returns: - bool: True if version check passes, False otherwise. + True if version check passes, False otherwise. """ try: if version.parse(crewai_version) < version.parse(MIN_REQUIRED_VERSION): @@ -57,8 +58,8 @@ def check_conversational_crews_version( def run_chat() -> None: - """ - Runs an interactive chat loop using the Crew's chat LLM with function calling. + """Run an interactive chat loop using the Crew's chat LLM with function calling. + Incorporates crew_name, crew_description, and input fields to build a tool schema. Exits if crew_name or crew_description are missing. """ @@ -73,14 +74,12 @@ def run_chat() -> None: if not chat_llm: return - # Indicate that the crew is being analyzed click.secho( "\nAnalyzing crew and required inputs - this may take 3 to 30 seconds " "depending on the complexity of your crew.", fg="white", ) - # Start loading indicator loading_complete = threading.Event() ctx = contextvars.copy_context() loading_thread = threading.Thread( @@ -93,16 +92,13 @@ def run_chat() -> None: crew_tool_schema = generate_crew_tool_schema(crew_chat_inputs) system_message = build_system_message(crew_chat_inputs) - # Call the LLM to generate the introductory message introductory_message = chat_llm.call( messages=[{"role": "system", "content": system_message}] ) finally: - # Stop loading indicator loading_complete.set() loading_thread.join() - # Indicate that the analysis is complete click.secho("\nFinished analyzing crew.\n", fg="white") click.secho(f"Assistant: {introductory_message}\n", fg="green") @@ -128,7 +124,7 @@ def show_loading(event: threading.Event) -> None: def initialize_chat_llm(crew: Crew) -> LLM | BaseLLM | None: - """Initializes the chat LLM and handles exceptions.""" + """Initialize the chat LLM and handle exceptions.""" try: return create_llm(crew.chat_llm) except Exception as e: @@ -140,7 +136,7 @@ def initialize_chat_llm(crew: Crew) -> LLM | BaseLLM | None: def build_system_message(crew_chat_inputs: ChatInputs) -> str: - """Builds the initial system message for the chat.""" + """Build the initial system message for the chat.""" required_fields_str = ( ", ".join( f"{field.name} (desc: {field.description or 'n/a'})" @@ -169,7 +165,7 @@ def build_system_message(crew_chat_inputs: ChatInputs) -> str: def create_tool_function(crew: Crew, messages: list[LLMMessage]) -> Any: - """Creates a wrapper function for running the crew tool with messages.""" + """Create a wrapper function for running the crew tool with messages.""" def run_crew_tool_with_messages(**kwargs: Any) -> str: return run_crew_tool(crew, messages, **kwargs) @@ -180,13 +176,11 @@ def create_tool_function(crew: Crew, messages: list[LLMMessage]) -> Any: def flush_input() -> None: """Flush any pending input from the user.""" if platform.system() == "Windows": - # Windows platform import msvcrt while msvcrt.kbhit(): # type: ignore[attr-defined] msvcrt.getch() # type: ignore[attr-defined] else: - # Unix-like platforms (Linux, macOS) import termios termios.tcflush(sys.stdin, termios.TCIFLUSH) @@ -201,7 +195,6 @@ def chat_loop( """Main chat loop for interacting with the user.""" while True: try: - # Flush any pending input before accepting new input flush_input() user_input = get_user_input() @@ -251,11 +244,9 @@ def handle_user_input( messages.append({"role": "user", "content": user_input}) - # Indicate that assistant is processing click.echo() click.secho("Assistant is processing your input. Please wait...", fg="green") - # Process assistant's response final_response = chat_llm.call( messages=messages, tools=[crew_tool_schema], @@ -267,12 +258,11 @@ def handle_user_input( def generate_crew_tool_schema(crew_inputs: ChatInputs) -> dict[str, Any]: - """ - Dynamically build a Littellm 'function' schema for the given crew. + """Dynamically build a Littellm 'function' schema for the given crew. - crew_name: The name of the crew (used for the function 'name'). - crew_inputs: A ChatInputs object containing crew_description - and a list of input fields (each with a name & description). + Args: + crew_inputs: A ChatInputs object containing crew_description + and a list of input fields (each with a name & description). """ properties = {} for field in crew_inputs.inputs: @@ -298,70 +288,51 @@ def generate_crew_tool_schema(crew_inputs: ChatInputs) -> dict[str, Any]: def run_crew_tool(crew: Crew, messages: list[LLMMessage], **kwargs: Any) -> str: - """ - Runs the crew using crew.kickoff(inputs=kwargs) and returns the output. + """Run the crew using crew.kickoff(inputs=kwargs) and return the output. Args: - crew (Crew): The crew instance to run. - messages (List[Dict[str, str]]): The chat messages up to this point. + crew: The crew instance to run. + messages: The chat messages up to this point. **kwargs: The inputs collected from the user. Returns: - str: The output from the crew's execution. - - Raises: - SystemExit: Exits the chat if an error occurs during crew execution. + The output from the crew's execution. """ try: - # Serialize 'messages' to JSON string before adding to kwargs kwargs["crew_chat_messages"] = json.dumps(messages) - - # Run the crew with the provided inputs crew_output = crew.kickoff(inputs=kwargs) - - # Convert CrewOutput to a string to send back to the user return str(crew_output) except Exception as e: - # Exit the chat and show the error message click.secho("An error occurred while running the crew:", fg="red") click.secho(str(e), fg="red") sys.exit(1) def load_crew_and_name() -> tuple[Crew, str]: - """ - Loads the crew by importing the crew class from the user's project. + """Load the crew by importing the crew class from the user's project. Returns: - Tuple[Crew, str]: A tuple containing the Crew instance and the name of the crew. + A tuple containing the Crew instance and the name of the crew. """ - # Get the current working directory cwd = Path.cwd() - # Path to the pyproject.toml file pyproject_path = cwd / "pyproject.toml" if not pyproject_path.exists(): raise FileNotFoundError("pyproject.toml not found in the current directory.") - # Load the pyproject.toml file using 'tomli' with pyproject_path.open("rb") as f: pyproject_data = tomli.load(f) - # Get the project name from the 'project' section project_name = pyproject_data["project"]["name"] folder_name = project_name - # Derive the crew class name from the project name - # E.g., if project_name is 'my_project', crew_class_name is 'MyProject' crew_class_name = project_name.replace("_", " ").title().replace(" ", "") - # Add the 'src' directory to sys.path src_path = cwd / "src" if str(src_path) not in sys.path: sys.path.insert(0, str(src_path)) - # Import the crew module crew_module_name = f"{folder_name}.crew" try: crew_module = __import__(crew_module_name, fromlist=[crew_class_name]) @@ -370,7 +341,6 @@ def load_crew_and_name() -> tuple[Crew, str]: f"Failed to import crew module {crew_module_name}: {e}" ) from e - # Get the crew class from the module try: crew_class = getattr(crew_module, crew_class_name) except AttributeError as e: @@ -378,7 +348,6 @@ def load_crew_and_name() -> tuple[Crew, str]: f"Crew class {crew_class_name} not found in module {crew_module_name}" ) from e - # Instantiate the crew crew_instance = crew_class().crew() return crew_instance, crew_class_name @@ -389,12 +358,11 @@ def generate_crew_chat_inputs( chat_llm: LLM | BaseLLM, generate_descriptions: bool = True, ) -> ChatInputs: - """ - Generates the ChatInputs required for the crew by analyzing the tasks and agents. + """Generate the ChatInputs required for the crew by analyzing the tasks and agents. Args: - crew (Crew): The crew object containing tasks and agents. - crew_name (str): The name of the crew. + crew: The crew object containing tasks and agents. + crew_name: The name of the crew. chat_llm: The chat language model to use for AI calls. generate_descriptions: When True (default), use the LLM to generate input and crew descriptions. When False, skip all LLM calls and @@ -402,7 +370,7 @@ def generate_crew_chat_inputs( startup should pass ``False`` to avoid blocking on the LLM. Returns: - ChatInputs: An object containing the crew's name, description, and input fields. + An object containing the crew's name, description, and input fields. """ required_inputs = fetch_required_inputs(crew) @@ -425,13 +393,13 @@ def generate_crew_chat_inputs( def fetch_required_inputs(crew: Crew) -> set[str]: - """Extracts placeholders from the crew's tasks and agents. + """Extract placeholders from the crew's tasks and agents. Args: - crew (Crew): The crew object. + crew: The crew object. Returns: - Set[str]: A set of placeholder names. + A set of placeholder names. """ return crew.fetch_inputs() @@ -439,18 +407,16 @@ def fetch_required_inputs(crew: Crew) -> set[str]: def generate_input_description_with_ai( input_name: str, crew: Crew, chat_llm: LLM | BaseLLM ) -> str: - """ - Generates an input description using AI based on the context of the crew. + """Generate an input description using AI based on the context of the crew. Args: - input_name (str): The name of the input placeholder. - crew (Crew): The crew object. + input_name: The name of the input placeholder. + crew: The crew object. chat_llm: The chat language model to use for AI calls. Returns: - str: A concise description of the input. + A concise description of the input. """ - # Gather context from tasks and agents where the input is used context_texts = [] placeholder_pattern = re.compile(r"\{(.+?)}") @@ -459,7 +425,6 @@ def generate_input_description_with_ai( f"{{{input_name}}}" in task.description or f"{{{input_name}}}" in task.expected_output ): - # Replace placeholders with input names task_description = placeholder_pattern.sub( lambda m: m.group(1), task.description or "" ) @@ -474,7 +439,6 @@ def generate_input_description_with_ai( or f"{{{input_name}}}" in agent.goal or f"{{{input_name}}}" in agent.backstory ): - # Replace placeholders with input names agent_role = placeholder_pattern.sub(lambda m: m.group(1), agent.role or "") agent_goal = placeholder_pattern.sub(lambda m: m.group(1), agent.goal or "") agent_backstory = placeholder_pattern.sub( @@ -486,7 +450,6 @@ def generate_input_description_with_ai( context = "\n".join(context_texts) if not context: - # If no context is found for the input, raise an exception as per instruction raise ValueError(f"No context found for input '{input_name}'.") prompt = ( @@ -508,22 +471,19 @@ def generate_input_description_with_ai( def generate_crew_description_with_ai(crew: Crew, chat_llm: LLM | BaseLLM) -> str: - """ - Generates a brief description of the crew using AI. + """Generate a brief description of the crew using AI. Args: - crew (Crew): The crew object. + crew: The crew object. chat_llm: The chat language model to use for AI calls. Returns: - str: A concise description of the crew's purpose (15 words or less). + A concise description of the crew's purpose (15 words or less). """ - # Gather context from tasks and agents context_texts = [] placeholder_pattern = re.compile(r"\{(.+?)}") for task in crew.tasks: - # Replace placeholders with input names task_description = placeholder_pattern.sub( lambda m: m.group(1), task.description or "" ) @@ -533,7 +493,6 @@ def generate_crew_description_with_ai(crew: Crew, chat_llm: LLM | BaseLLM) -> st context_texts.append(f"Task Description: {task_description}") context_texts.append(f"Expected Output: {expected_output}") for agent in crew.agents: - # Replace placeholders with input names agent_role = placeholder_pattern.sub(lambda m: m.group(1), agent.role or "") agent_goal = placeholder_pattern.sub(lambda m: m.group(1), agent.goal or "") agent_backstory = placeholder_pattern.sub( diff --git a/lib/crewai/src/crewai/utilities/file_handler.py b/lib/crewai/src/crewai/utilities/file_handler.py index c456d58df..437e267d8 100644 --- a/lib/crewai/src/crewai/utilities/file_handler.py +++ b/lib/crewai/src/crewai/utilities/file_handler.py @@ -4,10 +4,9 @@ import os import pickle from typing import Any, TypedDict +from crewai_core.lock_store import lock as store_lock from typing_extensions import Unpack -from crewai.utilities.lock_store import lock as store_lock - class LogEntry(TypedDict, total=False): """TypedDict for log entry kwargs with optional fields for flexibility.""" diff --git a/lib/crewai/src/crewai/utilities/llm_utils.py b/lib/crewai/src/crewai/utilities/llm_utils.py index 55a42968a..91c582b2f 100644 --- a/lib/crewai/src/crewai/utilities/llm_utils.py +++ b/lib/crewai/src/crewai/utilities/llm_utils.py @@ -2,7 +2,7 @@ import logging import os from typing import Any, Final -from crewai.cli.constants import DEFAULT_LLM_MODEL, ENV_VARS, LITELLM_PARAMS +from crewai.constants import DEFAULT_LLM_MODEL, ENV_VARS, LITELLM_PARAMS from crewai.llm import LLM from crewai.llms.base_llm import BaseLLM diff --git a/lib/crewai/src/crewai/utilities/lock_store.py b/lib/crewai/src/crewai/utilities/lock_store.py index 363448d8d..b84fbd7db 100644 --- a/lib/crewai/src/crewai/utilities/lock_store.py +++ b/lib/crewai/src/crewai/utilities/lock_store.py @@ -1,88 +1,17 @@ -"""Centralised lock factory. - -If ``REDIS_URL`` is set and the ``redis`` package is installed, locks are distributed via -``portalocker.RedisLock``. Otherwise, falls back to the standard ``portalocker.Lock``. -""" +"""Deprecated: use ``crewai_core.lock_store`` instead.""" from __future__ import annotations -from collections.abc import Iterator -from contextlib import contextmanager -from functools import lru_cache -from hashlib import md5 -import logging -import os -import tempfile -from typing import TYPE_CHECKING, Final +import warnings -import portalocker -import portalocker.exceptions +from crewai_core.lock_store import lock as lock -if TYPE_CHECKING: - import redis +__all__ = ["lock"] -logger = logging.getLogger(__name__) - -_REDIS_URL: str | None = os.environ.get("REDIS_URL") - -_DEFAULT_TIMEOUT: Final[int] = 120 - - -def _redis_available() -> bool: - """Return True if redis is installed and REDIS_URL is set.""" - if not _REDIS_URL: - return False - try: - import redis # noqa: F401 - - return True - except ImportError: - return False - - -@lru_cache(maxsize=1) -def _redis_connection() -> redis.Redis: - """Return a cached Redis connection, creating one on first call.""" - from redis import Redis - - if _REDIS_URL is None: - raise ValueError("REDIS_URL environment variable is not set") - return Redis.from_url(_REDIS_URL) - - -@contextmanager -def lock(name: str, *, timeout: float = _DEFAULT_TIMEOUT) -> Iterator[None]: - """Acquire a named lock, yielding while it is held. - - Args: - name: A human-readable lock name (e.g. ``"chromadb_init"``). - Automatically namespaced to avoid collisions. - timeout: Maximum seconds to wait for the lock before raising. - """ - channel = f"crewai:{md5(name.encode(), usedforsecurity=False).hexdigest()}" - - if _redis_available(): - with portalocker.RedisLock( - channel=channel, - connection=_redis_connection(), - timeout=timeout, - ): - yield - else: - lock_dir = tempfile.gettempdir() - lock_path = os.path.join(lock_dir, f"{channel}.lock") - try: - pl = portalocker.Lock(lock_path, timeout=timeout) - pl.acquire() - except portalocker.exceptions.BaseLockException as exc: - raise portalocker.exceptions.LockException( - f"Failed to acquire lock '{name}' at {lock_path} " - f"(timeout={timeout}s). This commonly occurs in " - f"multi-process environments. " - ) from exc - try: - yield - finally: - pl.release() # type: ignore[no-untyped-call] +warnings.warn( + "crewai.utilities.lock_store is deprecated; import from crewai_core.lock_store.", + DeprecationWarning, + stacklevel=2, +) diff --git a/lib/crewai/src/crewai/utilities/logger.py b/lib/crewai/src/crewai/utilities/logger.py index afc09d693..30c54a2e9 100644 --- a/lib/crewai/src/crewai/utilities/logger.py +++ b/lib/crewai/src/crewai/utilities/logger.py @@ -1,9 +1,8 @@ from datetime import datetime +from crewai_core.printer import PRINTER, ColoredText, PrinterColor from pydantic import BaseModel, Field -from crewai.utilities.printer import PRINTER, ColoredText, PrinterColor - class Logger(BaseModel): verbose: bool = Field( diff --git a/lib/crewai/src/crewai/utilities/paths.py b/lib/crewai/src/crewai/utilities/paths.py index 3612af9c7..138288381 100644 --- a/lib/crewai/src/crewai/utilities/paths.py +++ b/lib/crewai/src/crewai/utilities/paths.py @@ -1,25 +1,20 @@ -"""Path management utilities for CrewAI storage and configuration.""" +"""Deprecated: use ``crewai_core.paths`` instead.""" -import os -from pathlib import Path +from __future__ import annotations -import appdirs +import warnings + +from crewai_core.paths import ( + db_storage_path as db_storage_path, + get_project_directory_name as get_project_directory_name, +) -def db_storage_path() -> str: - """Returns the path for SQLite database storage. - - Returns: - str: Full path to the SQLite database file - """ - app_name = get_project_directory_name() - app_author = "CrewAI" - - data_dir = Path(appdirs.user_data_dir(app_name, app_author)) - data_dir.mkdir(parents=True, exist_ok=True) - return str(data_dir) +__all__ = ["db_storage_path", "get_project_directory_name"] -def get_project_directory_name() -> str: - """Returns the current project directory name.""" - return os.environ.get("CREWAI_STORAGE_DIR", Path.cwd().name) +warnings.warn( + "crewai.utilities.paths is deprecated; import from crewai_core.paths.", + DeprecationWarning, + stacklevel=2, +) diff --git a/lib/crewai/src/crewai/utilities/printer.py b/lib/crewai/src/crewai/utilities/printer.py index bb0dfecba..24cc87648 100644 --- a/lib/crewai/src/crewai/utilities/printer.py +++ b/lib/crewai/src/crewai/utilities/printer.py @@ -1,98 +1,22 @@ -"""Utility for colored console output.""" +"""Deprecated: use ``crewai_core.printer`` instead.""" from __future__ import annotations -from typing import TYPE_CHECKING, Final, Literal, NamedTuple +import warnings -from crewai.events.utils.console_formatter import should_suppress_console_output +from crewai_core.printer import ( + PRINTER as PRINTER, + ColoredText as ColoredText, + Printer as Printer, + PrinterColor as PrinterColor, +) -if TYPE_CHECKING: - from _typeshed import SupportsWrite - -PrinterColor = Literal[ - "purple", - "bold_purple", - "green", - "bold_green", - "cyan", - "bold_cyan", - "magenta", - "bold_magenta", - "yellow", - "bold_yellow", - "red", - "blue", - "bold_blue", -] - -_COLOR_CODES: Final[dict[PrinterColor, str]] = { - "purple": "\033[95m", - "bold_purple": "\033[1m\033[95m", - "red": "\033[91m", - "bold_green": "\033[1m\033[92m", - "green": "\033[32m", - "blue": "\033[94m", - "bold_blue": "\033[1m\033[94m", - "yellow": "\033[93m", - "bold_yellow": "\033[1m\033[93m", - "cyan": "\033[96m", - "bold_cyan": "\033[1m\033[96m", - "magenta": "\033[35m", - "bold_magenta": "\033[1m\033[35m", -} - -RESET: Final[str] = "\033[0m" +__all__ = ["PRINTER", "ColoredText", "Printer", "PrinterColor"] -class ColoredText(NamedTuple): - """Represents text with an optional color for console output. - - Attributes: - text: The text content to be printed. - color: Optional color for the text, specified as a PrinterColor. - """ - - text: str - color: PrinterColor | None - - -class Printer: - """Handles colored console output formatting.""" - - @staticmethod - def print( - content: str | list[ColoredText], - color: PrinterColor | None = None, - sep: str | None = " ", - end: str | None = "\n", - file: SupportsWrite[str] | None = None, - flush: Literal[False] = False, - ) -> None: - """Prints content to the console with optional color formatting. - - Args: - content: Either a string or a list of ColoredText objects for multicolor output. - color: Optional color for the text when content is a string. Ignored when content is a list. - sep: Separator to use between the text and color. - end: String appended after the last value. - file: A file-like object (stream); defaults to the current sys.stdout. - flush: Whether to forcibly flush the stream. - """ - if should_suppress_console_output(): - return - if isinstance(content, str): - content = [ColoredText(content, color)] - print( - "".join( - f"{_COLOR_CODES[c.color] if c.color else ''}{c.text}{RESET}" - for c in content - ), - sep=sep, - end=end, - file=file, - flush=flush, - ) - - -PRINTER: Printer = Printer() +warnings.warn( + "crewai.utilities.printer is deprecated; import from crewai_core.printer.", + DeprecationWarning, + stacklevel=2, +) diff --git a/lib/crewai/src/crewai/cli/utils.py b/lib/crewai/src/crewai/utilities/project_utils.py similarity index 63% rename from lib/crewai/src/crewai/cli/utils.py rename to lib/crewai/src/crewai/utilities/project_utils.py index ad8f5897e..c22b85a3c 100644 --- a/lib/crewai/src/crewai/cli/utils.py +++ b/lib/crewai/src/crewai/utilities/project_utils.py @@ -1,296 +1,69 @@ +"""Project utility functions for discovering crews, flows, and tools.""" + from collections.abc import Generator, Mapping from contextlib import contextmanager -from functools import lru_cache, reduce +from functools import lru_cache import hashlib import importlib.util import inspect from inspect import getmro, isclass, isfunction, ismethod import os from pathlib import Path -import shutil import sys import types from typing import Any, cast, get_type_hints -import click +from crewai_core.project import ( + get_project_description as get_project_description, + get_project_name as get_project_name, + get_project_version as get_project_version, + parse_toml as parse_toml, + read_toml as read_toml, +) +from crewai_core.tool_credentials import ( + build_env_with_all_tool_credentials as build_env_with_all_tool_credentials, + build_env_with_tool_repository_credentials as build_env_with_tool_repository_credentials, +) from rich.console import Console -import tomli -from crewai.cli.config import Settings -from crewai.cli.constants import ENV_VARS from crewai.crew import Crew from crewai.flow import Flow -if sys.version_info >= (3, 11): - import tomllib +__all__ = [ + "build_env_with_all_tool_credentials", + "build_env_with_tool_repository_credentials", + "extract_available_exports", + "extract_tools_metadata", + "fetch_crews", + "get_crew_instance", + "get_crews", + "get_flow_instance", + "get_flows", + "get_project_description", + "get_project_name", + "get_project_version", + "is_valid_tool", + "parse_toml", + "read_toml", +] + console = Console() -def copy_template( - src: Path, dst: Path, name: str, class_name: str, folder_name: str -) -> None: - """Copy a file from src to dst.""" - with open(src, "r") as file: - content = file.read() - - # Interpolate the content - content = content.replace("{{name}}", name) - content = content.replace("{{crew_name}}", class_name) - content = content.replace("{{folder_name}}", folder_name) - - # Write the interpolated content to the new file - with open(dst, "w") as file: - file.write(content) - - click.secho(f" - Created {dst}", fg="green") - - -def read_toml(file_path: str = "pyproject.toml") -> dict[str, Any]: - """Read the content of a TOML file and return it as a dictionary.""" - with open(file_path, "rb") as f: - return tomli.load(f) - - -def parse_toml(content: str) -> dict[str, Any]: - if sys.version_info >= (3, 11): - return tomllib.loads(content) - return tomli.loads(content) - - -def get_project_name( - pyproject_path: str = "pyproject.toml", require: bool = False -) -> str | None: - """Get the project name from the pyproject.toml file.""" - return _get_project_attribute(pyproject_path, ["project", "name"], require=require) - - -def get_project_version( - pyproject_path: str = "pyproject.toml", require: bool = False -) -> str | None: - """Get the project version from the pyproject.toml file.""" - return _get_project_attribute( - pyproject_path, ["project", "version"], require=require - ) - - -def get_project_description( - pyproject_path: str = "pyproject.toml", require: bool = False -) -> str | None: - """Get the project description from the pyproject.toml file.""" - return _get_project_attribute( - pyproject_path, ["project", "description"], require=require - ) - - -def _get_project_attribute( - pyproject_path: str, keys: list[str], require: bool -) -> Any | None: - """Get an attribute from the pyproject.toml file.""" - attribute = None - - try: - with open(pyproject_path, "r") as f: - pyproject_content = parse_toml(f.read()) - - dependencies = ( - _get_nested_value(pyproject_content, ["project", "dependencies"]) or [] - ) - if not any(True for dep in dependencies if "crewai" in dep): - raise Exception("crewai is not in the dependencies.") - - attribute = _get_nested_value(pyproject_content, keys) - except FileNotFoundError: - console.print(f"Error: {pyproject_path} not found.", style="bold red") - except KeyError: - console.print( - f"Error: {pyproject_path} is not a valid pyproject.toml file.", - style="bold red", - ) - except Exception as e: - # Handle TOML decode errors for Python 3.11+ - if sys.version_info >= (3, 11) and isinstance(e, tomllib.TOMLDecodeError): - console.print( - f"Error: {pyproject_path} is not a valid TOML file.", style="bold red" - ) - else: - console.print( - f"Error reading the pyproject.toml file: {e}", style="bold red" - ) - - if require and not attribute: - console.print( - f"Unable to read '{'.'.join(keys)}' in the pyproject.toml file. Please verify that the file exists and contains the specified attribute.", - style="bold red", - ) - raise SystemExit - - return attribute - - -def _get_nested_value(data: dict[str, Any], keys: list[str]) -> Any: - return reduce(dict.__getitem__, keys, data) - - -def fetch_and_json_env_file(env_file_path: str = ".env") -> dict[str, Any]: - """Fetch the environment variables from a .env file and return them as a dictionary.""" - try: - # Read the .env file - with open(env_file_path, "r") as f: - env_content = f.read() - - # Parse the .env file content to a dictionary - env_dict = {} - for line in env_content.splitlines(): - if line.strip() and not line.strip().startswith("#"): - key, value = line.split("=", 1) - env_dict[key.strip()] = value.strip() - - return env_dict - - except FileNotFoundError: - console.print(f"Error: {env_file_path} not found.", style="bold red") - except Exception as e: - console.print(f"Error reading the .env file: {e}", style="bold red") - - return {} - - -def tree_copy(source: Path, destination: Path) -> None: - """Copies the entire directory structure from the source to the destination.""" - for item in os.listdir(source): - source_item = os.path.join(source, item) - destination_item = os.path.join(destination, item) - if os.path.isdir(source_item): - shutil.copytree(source_item, destination_item) - else: - shutil.copy2(source_item, destination_item) - - -def tree_find_and_replace(directory: Path, find: str, replace: str) -> None: - """Recursively searches through a directory, replacing a target string in - both file contents and filenames with a specified replacement string. - """ - for path, dirs, files in os.walk(os.path.abspath(directory), topdown=False): - for filename in files: - filepath = os.path.join(path, filename) - - with open(filepath, "r", encoding="utf-8", errors="ignore") as file: - contents = file.read() - with open(filepath, "w") as file: - file.write(contents.replace(find, replace)) - - if find in filename: - new_filename = filename.replace(find, replace) - new_filepath = os.path.join(path, new_filename) - os.rename(filepath, new_filepath) - - for dirname in dirs: - if find in dirname: - new_dirname = dirname.replace(find, replace) - new_dirpath = os.path.join(path, new_dirname) - old_dirpath = os.path.join(path, dirname) - os.rename(old_dirpath, new_dirpath) - - -def load_env_vars(folder_path: Path) -> dict[str, Any]: - """ - Loads environment variables from a .env file in the specified folder path. - - Args: - - folder_path (Path): The path to the folder containing the .env file. - - Returns: - - dict: A dictionary of environment variables. - """ - env_file_path = folder_path / ".env" - env_vars = {} - if env_file_path.exists(): - with open(env_file_path, "r") as file: - for line in file: - key, _, value = line.strip().partition("=") - if key and value: - env_vars[key] = value - return env_vars - - -def update_env_vars( - env_vars: dict[str, Any], provider: str, model: str -) -> dict[str, Any] | None: - """ - Updates environment variables with the API key for the selected provider and model. - - Args: - - env_vars (dict): Environment variables dictionary. - - provider (str): Selected provider. - - model (str): Selected model. - - Returns: - - None - """ - provider_config = cast( - list[str], - ENV_VARS.get( - provider, - [ - click.prompt( - f"Enter the environment variable name for your {provider.capitalize()} API key", - type=str, - ) - ], - ), - ) - - api_key_var = provider_config[0] - - if api_key_var not in env_vars: - try: - env_vars[api_key_var] = click.prompt( - f"Enter your {provider.capitalize()} API key", type=str, hide_input=True - ) - except click.exceptions.Abort: - click.secho("Operation aborted by the user.", fg="red") - return None - else: - click.secho(f"API key already exists for {provider.capitalize()}.", fg="yellow") - - env_vars["MODEL"] = model - click.secho(f"Selected model: {model}", fg="green") - return env_vars - - -def write_env_file(folder_path: Path, env_vars: dict[str, Any]) -> None: - """ - Writes environment variables to a .env file in the specified folder. - - Args: - - folder_path (Path): The path to the folder where the .env file will be written. - - env_vars (dict): A dictionary of environment variables to write. - """ - env_file_path = folder_path / ".env" - with open(env_file_path, "w") as file: - for key, value in env_vars.items(): - file.write(f"{key.upper()}={value}\n") - - def get_crews(crew_path: str = "crew.py", require: bool = False) -> list[Crew]: """Get the crew instances from a file.""" crew_instances = [] try: - import importlib.util - - # Add the current directory to sys.path to ensure imports resolve correctly current_dir = os.getcwd() if current_dir not in sys.path: sys.path.insert(0, current_dir) - # If we're not in src directory but there's a src directory, add it to path src_dir = os.path.join(current_dir, "src") if os.path.isdir(src_dir) and src_dir not in sys.path: sys.path.insert(0, src_dir) - # Search in both current directory and src directory if it exists search_paths = [".", "src"] if os.path.isdir("src") else ["."] for search_path in search_paths: @@ -321,7 +94,6 @@ def get_crews(crew_path: str = "crew.py", require: bool = False) -> list[Crew]: ) continue - # If we found crew instances, break out of the loop if crew_instances: break @@ -339,7 +111,6 @@ def get_crews(crew_path: str = "crew.py", require: bool = False) -> list[Crew]: ) continue - # If we found crew instances in this search path, break out of the search paths loop if crew_instances: break @@ -357,6 +128,7 @@ def get_crews(crew_path: str = "crew.py", require: bool = False) -> list[Crew]: def get_crew_instance(module_attr: Any) -> Crew | None: + """Get a Crew instance from a module attribute.""" if ( callable(module_attr) and hasattr(module_attr, "is_crew_class") @@ -377,6 +149,7 @@ def get_crew_instance(module_attr: Any) -> Crew | None: def fetch_crews(module_attr: Any) -> list[Crew]: + """Fetch crew instances from a module attribute.""" crew_instances: list[Crew] = [] if crew_instance := get_crew_instance(module_attr): @@ -423,8 +196,7 @@ def get_flows(flow_path: str = "main.py") -> list[Flow[Any]]: Walks the project directory looking for files matching ``flow_path`` (default ``main.py``), loads each module, and extracts Flow subclass - instances. Directories that are clearly not user source code (virtual - environments, ``.git``, etc.) are pruned to avoid noisy import errors. + instances. Args: flow_path: Filename to search for (default ``main.py``). @@ -495,6 +267,7 @@ def get_flows(flow_path: str = "main.py") -> list[Flow[Any]]: def is_valid_tool(obj: Any) -> bool: + """Check if an object is a valid CrewAI tool.""" from crewai.tools.base_tool import Tool if isclass(obj): @@ -507,12 +280,12 @@ def is_valid_tool(obj: Any) -> bool: def extract_available_exports(dir_path: str = "src") -> list[dict[str, Any]]: - """ - Extract available tool classes from the project's __init__.py files. + """Extract available tool classes from the project's __init__.py files. + Only includes classes that inherit from BaseTool or functions decorated with @tool. Returns: - list: A list of valid tool class names or ["BaseTool"] if none found + A list of valid tool class names or ["BaseTool"] if none found. """ try: init_files = Path(dir_path).glob("**/__init__.py") @@ -536,48 +309,6 @@ def extract_available_exports(dir_path: str = "src") -> list[dict[str, Any]]: raise SystemExit(1) from e -def build_env_with_tool_repository_credentials( - repository_handle: str, -) -> dict[str, Any]: - repository_handle = repository_handle.upper().replace("-", "_") - settings = Settings() - - env = os.environ.copy() - env[f"UV_INDEX_{repository_handle}_USERNAME"] = str( - settings.tool_repository_username or "" - ) - env[f"UV_INDEX_{repository_handle}_PASSWORD"] = str( - settings.tool_repository_password or "" - ) - - return env - - -def build_env_with_all_tool_credentials() -> dict[str, Any]: - """ - Build environment dict with credentials for all tool repository indexes - found in pyproject.toml's [tool.uv.sources] section. - - Returns: - dict: Environment variables with credentials for all private indexes. - """ - env = os.environ.copy() - try: - pyproject_data = read_toml() - sources = pyproject_data.get("tool", {}).get("uv", {}).get("sources", {}) - - for source_config in sources.values(): - if isinstance(source_config, dict): - index = source_config.get("index") - if index: - index_env = build_env_with_tool_repository_credentials(index) - env.update(index_env) - except Exception: # noqa: S110 - pass - - return env - - @contextmanager def _load_module_from_file( init_file: Path, module_name: str | None = None @@ -608,9 +339,7 @@ def _load_module_from_file( def _load_tools_from_init(init_file: Path) -> list[dict[str, Any]]: - """ - Load and validate tools from a given __init__.py file. - """ + """Load and validate tools from a given __init__.py file.""" try: with _load_module_from_file(init_file) as module: if module is None: @@ -636,9 +365,7 @@ def _load_tools_from_init(init_file: Path) -> list[dict[str, Any]]: def _print_no_tools_warning() -> None: - """ - Display warning and usage instructions if no tools were found. - """ + """Display warning and usage instructions if no tools were found.""" console.print( "\n[bold yellow]Warning: No valid tools were exposed in your __init__.py file![/bold yellow]" ) diff --git a/lib/crewai/src/crewai/cli/reset_memories_command.py b/lib/crewai/src/crewai/utilities/reset_memories.py similarity index 96% rename from lib/crewai/src/crewai/cli/reset_memories_command.py rename to lib/crewai/src/crewai/utilities/reset_memories.py index 01bab07d9..50d4a633e 100644 --- a/lib/crewai/src/crewai/cli/reset_memories_command.py +++ b/lib/crewai/src/crewai/utilities/reset_memories.py @@ -1,10 +1,12 @@ +"""Memory reset utilities for CrewAI crews and flows.""" + import subprocess from typing import Any import click -from crewai.cli.utils import get_crews, get_flows from crewai.flow import Flow +from crewai.utilities.project_utils import get_crews, get_flows def _reset_flow_memory(flow: Flow[Any]) -> None: diff --git a/lib/crewai/src/crewai/utilities/version.py b/lib/crewai/src/crewai/utilities/version.py index 57a5c562d..518e5ba70 100644 --- a/lib/crewai/src/crewai/utilities/version.py +++ b/lib/crewai/src/crewai/utilities/version.py @@ -1,12 +1,17 @@ -"""Version utilities for crewAI.""" +"""Deprecated: use ``crewai_core.version`` instead.""" from __future__ import annotations -from functools import cache -import importlib.metadata +import warnings + +from crewai_core.version import get_crewai_version as get_crewai_version -@cache -def get_crewai_version() -> str: - """Get the installed crewAI version string.""" - return importlib.metadata.version("crewai") +__all__ = ["get_crewai_version"] + + +warnings.warn( + "crewai.utilities.version is deprecated; import from crewai_core.version.", + DeprecationWarning, + stacklevel=2, +) diff --git a/lib/crewai/src/crewai/version.py b/lib/crewai/src/crewai/version.py new file mode 100644 index 000000000..2016621b5 --- /dev/null +++ b/lib/crewai/src/crewai/version.py @@ -0,0 +1,24 @@ +"""Re-exports of version utilities from ``crewai_core.version``. + +Kept as a stable import path for the framework; new code should import from +``crewai_core.version`` directly. +""" + +from __future__ import annotations + +from crewai_core.version import ( + check_version as check_version, + get_crewai_version as get_crewai_version, + get_latest_version_from_pypi as get_latest_version_from_pypi, + is_current_version_yanked as is_current_version_yanked, + is_newer_version_available as is_newer_version_available, +) + + +__all__ = [ + "check_version", + "get_crewai_version", + "get_latest_version_from_pypi", + "is_current_version_yanked", + "is_newer_version_available", +] diff --git a/lib/crewai/tests/agents/test_agent.py b/lib/crewai/tests/agents/test_agent.py index 037f70af4..eae628fce 100644 --- a/lib/crewai/tests/agents/test_agent.py +++ b/lib/crewai/tests/agents/test_agent.py @@ -6,7 +6,7 @@ from unittest import mock from unittest.mock import MagicMock, patch from crewai.agents.crew_agent_executor import AgentFinish, CrewAgentExecutor -from crewai.cli.constants import DEFAULT_LLM_MODEL +from crewai.constants import DEFAULT_LLM_MODEL from crewai.events.event_bus import crewai_event_bus from crewai.events.types.tool_usage_events import ToolUsageFinishedEvent from crewai.knowledge.knowledge import Knowledge @@ -1225,7 +1225,7 @@ def test_llm_call_with_error(): def test_handle_context_length_exceeds_limit(): # Import necessary modules from crewai.utilities.agent_utils import handle_context_length - from crewai.utilities.printer import Printer + from crewai_core.printer import Printer # Create mocks for dependencies printer = Printer() @@ -2080,12 +2080,12 @@ def test_get_knowledge_search_query(): @pytest.fixture def mock_get_auth_token(): with patch( - "crewai.cli.authentication.token.get_auth_token", return_value="test_token" + "crewai.auth.token.get_auth_token", return_value="test_token" ): yield -@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.plus_api.PlusAPI.get_agent") def test_agent_from_repository(mock_get_agent, mock_get_auth_token): from crewai_tools import ( FileReadTool, @@ -2126,7 +2126,7 @@ def test_agent_from_repository(mock_get_agent, mock_get_auth_token): assert agent.tools[1].file_path == "test.txt" -@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.plus_api.PlusAPI.get_agent") def test_agent_from_repository_override_attributes(mock_get_agent, mock_get_auth_token): from crewai_tools import SerperDevTool @@ -2150,7 +2150,7 @@ def test_agent_from_repository_override_attributes(mock_get_agent, mock_get_auth assert isinstance(agent.tools[0], SerperDevTool) -@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.plus_api.PlusAPI.get_agent") def test_agent_from_repository_with_invalid_tools(mock_get_agent, mock_get_auth_token): mock_get_response = MagicMock() mock_get_response.status_code = 200 @@ -2173,7 +2173,7 @@ def test_agent_from_repository_with_invalid_tools(mock_get_agent, mock_get_auth_ Agent(from_repository="test_agent") -@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.plus_api.PlusAPI.get_agent") def test_agent_from_repository_internal_error(mock_get_agent, mock_get_auth_token): mock_get_response = MagicMock() mock_get_response.status_code = 500 @@ -2186,7 +2186,7 @@ def test_agent_from_repository_internal_error(mock_get_agent, mock_get_auth_toke Agent(from_repository="test_agent") -@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.plus_api.PlusAPI.get_agent") def test_agent_from_repository_agent_not_found(mock_get_agent, mock_get_auth_token): mock_get_response = MagicMock() mock_get_response.status_code = 404 @@ -2199,7 +2199,7 @@ def test_agent_from_repository_agent_not_found(mock_get_agent, mock_get_auth_tok Agent(from_repository="test_agent") -@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.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( @@ -2232,7 +2232,7 @@ def test_agent_from_repository_displays_org_info( assert agent.backstory == "test backstory" -@patch("crewai.cli.plus_api.PlusAPI.get_agent") +@patch("crewai.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( diff --git a/lib/crewai/tests/agents/test_async_agent_executor.py b/lib/crewai/tests/agents/test_async_agent_executor.py index e4dc7f63f..285005c8f 100644 --- a/lib/crewai/tests/agents/test_async_agent_executor.py +++ b/lib/crewai/tests/agents/test_async_agent_executor.py @@ -405,7 +405,7 @@ class TestAsyncLLMResponseHelper: async def test_aget_llm_response_calls_acall(self) -> None: """Test that aget_llm_response calls llm.acall.""" from crewai.utilities.agent_utils import aget_llm_response - from crewai.utilities.printer import Printer + from crewai_core.printer import Printer mock_llm = MagicMock() mock_llm.acall = AsyncMock(return_value="LLM response") @@ -424,7 +424,7 @@ class TestAsyncLLMResponseHelper: async def test_aget_llm_response_raises_on_empty_response(self) -> None: """Test that aget_llm_response raises ValueError on empty response.""" from crewai.utilities.agent_utils import aget_llm_response - from crewai.utilities.printer import Printer + from crewai_core.printer import Printer mock_llm = MagicMock() mock_llm.acall = AsyncMock(return_value="") @@ -441,7 +441,7 @@ class TestAsyncLLMResponseHelper: async def test_aget_llm_response_propagates_exceptions(self) -> None: """Test that aget_llm_response propagates LLM exceptions.""" from crewai.utilities.agent_utils import aget_llm_response - from crewai.utilities.printer import Printer + from crewai_core.printer import Printer mock_llm = MagicMock() mock_llm.acall = AsyncMock(side_effect=RuntimeError("LLM error")) diff --git a/lib/crewai/tests/cli/authentication/providers/test_auth0.py b/lib/crewai/tests/cli/authentication/providers/test_auth0.py index e513a1fb7..7b2c40edc 100644 --- a/lib/crewai/tests/cli/authentication/providers/test_auth0.py +++ b/lib/crewai/tests/cli/authentication/providers/test_auth0.py @@ -1,6 +1,6 @@ import pytest -from crewai.cli.authentication.main import Oauth2Settings -from crewai.cli.authentication.providers.auth0 import Auth0Provider +from crewai.auth.oauth2 import Oauth2Settings +from crewai.auth.providers.auth0 import Auth0Provider diff --git a/lib/crewai/tests/cli/authentication/providers/test_entra_id.py b/lib/crewai/tests/cli/authentication/providers/test_entra_id.py index 889023955..4237a6054 100644 --- a/lib/crewai/tests/cli/authentication/providers/test_entra_id.py +++ b/lib/crewai/tests/cli/authentication/providers/test_entra_id.py @@ -1,7 +1,7 @@ import pytest -from crewai.cli.authentication.main import Oauth2Settings -from crewai.cli.authentication.providers.entra_id import EntraIdProvider +from crewai.auth.oauth2 import Oauth2Settings +from crewai.auth.providers.entra_id import EntraIdProvider class TestEntraIdProvider: diff --git a/lib/crewai/tests/cli/authentication/providers/test_keycloak.py b/lib/crewai/tests/cli/authentication/providers/test_keycloak.py index 05d71b271..cf87e6625 100644 --- a/lib/crewai/tests/cli/authentication/providers/test_keycloak.py +++ b/lib/crewai/tests/cli/authentication/providers/test_keycloak.py @@ -1,7 +1,7 @@ import pytest -from crewai.cli.authentication.main import Oauth2Settings -from crewai.cli.authentication.providers.keycloak import KeycloakProvider +from crewai.auth.oauth2 import Oauth2Settings +from crewai.auth.providers.keycloak import KeycloakProvider class TestKeycloakProvider: diff --git a/lib/crewai/tests/cli/authentication/providers/test_okta.py b/lib/crewai/tests/cli/authentication/providers/test_okta.py index 5108b1bb6..ec76202ca 100644 --- a/lib/crewai/tests/cli/authentication/providers/test_okta.py +++ b/lib/crewai/tests/cli/authentication/providers/test_okta.py @@ -1,7 +1,7 @@ import pytest -from crewai.cli.authentication.main import Oauth2Settings -from crewai.cli.authentication.providers.okta import OktaProvider +from crewai.auth.oauth2 import Oauth2Settings +from crewai.auth.providers.okta import OktaProvider class TestOktaProvider: diff --git a/lib/crewai/tests/cli/authentication/providers/test_workos.py b/lib/crewai/tests/cli/authentication/providers/test_workos.py index 7eda774d6..791bc531b 100644 --- a/lib/crewai/tests/cli/authentication/providers/test_workos.py +++ b/lib/crewai/tests/cli/authentication/providers/test_workos.py @@ -1,6 +1,6 @@ import pytest -from crewai.cli.authentication.main import Oauth2Settings -from crewai.cli.authentication.providers.workos import WorkosProvider +from crewai.auth.oauth2 import Oauth2Settings +from crewai.auth.providers.workos import WorkosProvider class TestWorkosProvider: diff --git a/lib/crewai/tests/cli/authentication/test_utils.py b/lib/crewai/tests/cli/authentication/test_utils.py index 5df00db18..22f5357f2 100644 --- a/lib/crewai/tests/cli/authentication/test_utils.py +++ b/lib/crewai/tests/cli/authentication/test_utils.py @@ -3,11 +3,11 @@ from unittest.mock import MagicMock, patch import jwt -from crewai.cli.authentication.utils import validate_jwt_token +from crewai.auth.utils import validate_jwt_token -@patch("crewai.cli.authentication.utils.PyJWKClient", return_value=MagicMock()) -@patch("crewai.cli.authentication.utils.jwt") +@patch("crewai_core.auth.utils.PyJWKClient", return_value=MagicMock()) +@patch("crewai_core.auth.utils.jwt") class TestUtils(unittest.TestCase): def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient): mock_jwt.decode.return_value = {"exp": 1719859200} diff --git a/lib/crewai/tests/cli/remote_template/test_main.py b/lib/crewai/tests/cli/remote_template/test_main.py index 829e956ce..2a4e73c4a 100644 --- a/lib/crewai/tests/cli/remote_template/test_main.py +++ b/lib/crewai/tests/cli/remote_template/test_main.py @@ -7,8 +7,8 @@ import httpx import pytest from click.testing import CliRunner -from crewai.cli.cli import template_add, template_list -from crewai.cli.remote_template.main import TemplateCommand +from crewai_cli.cli import template_add, template_list +from crewai_cli.remote_template.main import TemplateCommand @pytest.fixture @@ -38,7 +38,7 @@ def _make_zipball(files: dict[str, str], top_dir: str = "crewAIInc-template_test # --- CLI command tests --- -@patch("crewai.cli.cli.TemplateCommand") +@patch("crewai_cli.cli.TemplateCommand") def test_template_list_command(mock_cls, runner): mock_instance = MagicMock() mock_cls.return_value = mock_instance @@ -50,7 +50,7 @@ def test_template_list_command(mock_cls, runner): mock_instance.list_templates.assert_called_once() -@patch("crewai.cli.cli.TemplateCommand") +@patch("crewai_cli.cli.TemplateCommand") def test_template_add_command(mock_cls, runner): mock_instance = MagicMock() mock_cls.return_value = mock_instance @@ -62,7 +62,7 @@ def test_template_add_command(mock_cls, runner): mock_instance.add_template.assert_called_once_with("deep_research", None) -@patch("crewai.cli.cli.TemplateCommand") +@patch("crewai_cli.cli.TemplateCommand") def test_template_add_with_output_dir(mock_cls, runner): mock_instance = MagicMock() mock_cls.return_value = mock_instance @@ -84,7 +84,7 @@ class TestTemplateCommand: instance._telemetry = MagicMock() return instance - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.httpx.get") def test_fetch_templates_filters_by_prefix(self, mock_get, cmd): mock_response = MagicMock() mock_response.json.return_value = SAMPLE_REPOS @@ -100,7 +100,7 @@ class TestTemplateCommand: assert len(templates) == 3 assert all(t["name"].startswith("template_") for t in templates) - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.httpx.get") def test_fetch_templates_excludes_private(self, mock_get, cmd): repos = [ {"name": "template_private_one", "description": "", "private": True}, @@ -119,15 +119,15 @@ class TestTemplateCommand: assert len(templates) == 1 assert templates[0]["name"] == "template_public_one" - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.httpx.get") def test_fetch_templates_api_error(self, mock_get, cmd): mock_get.side_effect = httpx.HTTPError("connection error") with pytest.raises(SystemExit): cmd._fetch_templates() - @patch("crewai.cli.remote_template.main.click.prompt", return_value="q") - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.click.prompt", return_value="q") + @patch("crewai_cli.remote_template.main.httpx.get") def test_list_templates_prints_output(self, mock_get, mock_prompt, cmd): mock_response = MagicMock() mock_response.json.return_value = SAMPLE_REPOS @@ -137,11 +137,11 @@ class TestTemplateCommand: mock_empty.raise_for_status = MagicMock() mock_get.side_effect = [mock_response, mock_empty] - with patch("crewai.cli.remote_template.main.console") as mock_console: + with patch("crewai_cli.remote_template.main.console") as mock_console: cmd.list_templates() assert mock_console.print.call_count > 0 - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.httpx.get") def test_resolve_repo_name_with_prefix(self, mock_get, cmd): mock_response = MagicMock() mock_response.json.return_value = SAMPLE_REPOS @@ -154,7 +154,7 @@ class TestTemplateCommand: result = cmd._resolve_repo_name("template_deep_research") assert result == "template_deep_research" - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.httpx.get") def test_resolve_repo_name_without_prefix(self, mock_get, cmd): mock_response = MagicMock() mock_response.json.return_value = SAMPLE_REPOS @@ -167,7 +167,7 @@ class TestTemplateCommand: result = cmd._resolve_repo_name("deep_research") assert result == "template_deep_research" - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.httpx.get") def test_resolve_repo_name_not_found(self, mock_get, cmd): mock_response = MagicMock() mock_response.json.return_value = SAMPLE_REPOS @@ -222,7 +222,7 @@ class TestTemplateCommand: @patch.object(TemplateCommand, "_extract_zip") @patch.object(TemplateCommand, "_download_zip") - @patch("crewai.cli.remote_template.main.click.prompt", return_value="my_project") + @patch("crewai_cli.remote_template.main.click.prompt", return_value="my_project") @patch.object(TemplateCommand, "_resolve_repo_name") def test_add_template_dir_exists_prompts_rename(self, mock_resolve, mock_prompt, mock_download, mock_extract, cmd, tmp_path): mock_resolve.return_value = "template_deep_research" @@ -237,7 +237,7 @@ class TestTemplateCommand: mock_extract.assert_called_once_with(b"fake-zip-bytes", expected_dest) @patch.object(TemplateCommand, "_resolve_repo_name") - @patch("crewai.cli.remote_template.main.click.prompt", return_value="q") + @patch("crewai_cli.remote_template.main.click.prompt", return_value="q") def test_add_template_dir_exists_quit(self, mock_prompt, mock_resolve, cmd, tmp_path): mock_resolve.return_value = "template_deep_research" existing = tmp_path / "deep_research" @@ -248,8 +248,8 @@ class TestTemplateCommand: # Should return without downloading @patch.object(TemplateCommand, "_install_repo") - @patch("crewai.cli.remote_template.main.click.prompt", return_value="2") - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.click.prompt", return_value="2") + @patch("crewai_cli.remote_template.main.httpx.get") def test_list_templates_selects_and_installs(self, mock_get, mock_prompt, mock_install, cmd): mock_response = MagicMock() mock_response.json.return_value = SAMPLE_REPOS @@ -259,15 +259,15 @@ class TestTemplateCommand: mock_empty.raise_for_status = MagicMock() mock_get.side_effect = [mock_response, mock_empty] - with patch("crewai.cli.remote_template.main.console"): + with patch("crewai_cli.remote_template.main.console"): cmd.list_templates() # Templates are sorted by name; index 1 (choice "2") = template_deep_research mock_install.assert_called_once_with("template_deep_research") @patch.object(TemplateCommand, "_install_repo") - @patch("crewai.cli.remote_template.main.click.prompt", return_value="q") - @patch("crewai.cli.remote_template.main.httpx.get") + @patch("crewai_cli.remote_template.main.click.prompt", return_value="q") + @patch("crewai_cli.remote_template.main.httpx.get") def test_list_templates_quit(self, mock_get, mock_prompt, mock_install, cmd): mock_response = MagicMock() mock_response.json.return_value = SAMPLE_REPOS @@ -277,7 +277,7 @@ class TestTemplateCommand: mock_empty.raise_for_status = MagicMock() mock_get.side_effect = [mock_response, mock_empty] - with patch("crewai.cli.remote_template.main.console"): + with patch("crewai_cli.remote_template.main.console"): cmd.list_templates() mock_install.assert_not_called() diff --git a/lib/crewai/tests/cli/test_cli.py b/lib/crewai/tests/cli/test_cli.py index decb15e70..e4710564c 100644 --- a/lib/crewai/tests/cli/test_cli.py +++ b/lib/crewai/tests/cli/test_cli.py @@ -1,22 +1,14 @@ -from pathlib import Path +"""Tests for CLI commands that require crewai core (reset-memories). + +Non-core CLI tests (train, test, version, deploy, login, flow_add_crew) +have moved to lib/cli/tests/test_cli.py. +""" + from unittest import mock import pytest from click.testing import CliRunner -from crewai.cli.cli import ( - deploy_create, - deploy_list, - deploy_logs, - deploy_push, - deploy_remove, - deply_status, - flow_add_crew, - login, - reset_memories, - test, - train, - version, -) +from crewai_cli.cli import reset_memories from crewai.crew import Crew @@ -25,36 +17,6 @@ def runner(): return CliRunner() -@mock.patch("crewai.cli.cli.train_crew") -def test_train_default_iterations(train_crew, runner): - result = runner.invoke(train) - - train_crew.assert_called_once_with(5, "trained_agents_data.pkl") - assert result.exit_code == 0 - assert "Training the Crew for 5 iterations" in result.output - - -@mock.patch("crewai.cli.cli.train_crew") -def test_train_custom_iterations(train_crew, runner): - result = runner.invoke(train, ["--n_iterations", "10"]) - - train_crew.assert_called_once_with(10, "trained_agents_data.pkl") - assert result.exit_code == 0 - assert "Training the Crew for 10 iterations" in result.output - - -@mock.patch("crewai.cli.cli.train_crew") -def test_train_invalid_string_iterations(train_crew, runner): - result = runner.invoke(train, ["--n_iterations", "invalid"]) - - train_crew.assert_not_called() - assert result.exit_code == 2 - assert ( - "Usage: train [OPTIONS]\nTry 'train --help' for help.\n\nError: Invalid value for '-n' / '--n_iterations': 'invalid' is not a valid integer.\n" - in result.output - ) - - @pytest.fixture def mock_crew(): _mock = mock.Mock(spec=Crew, name="test_crew") @@ -65,9 +27,9 @@ def mock_crew(): @pytest.fixture def mock_get_crews(mock_crew): with mock.patch( - "crewai.cli.reset_memories_command.get_crews", return_value=[mock_crew] + "crewai.utilities.reset_memories.get_crews", return_value=[mock_crew] ) as mock_get_crew, mock.patch( - "crewai.cli.reset_memories_command.get_flows", return_value=[] + "crewai.utilities.reset_memories.get_flows", return_value=[] ): yield mock_get_crew @@ -207,9 +169,9 @@ def mock_flow(): @pytest.fixture def mock_get_flows(mock_flow): with mock.patch( - "crewai.cli.reset_memories_command.get_flows", return_value=[mock_flow] + "crewai.utilities.reset_memories.get_flows", return_value=[mock_flow] ) as mock_get_flow, mock.patch( - "crewai.cli.reset_memories_command.get_crews", return_value=[] + "crewai.utilities.reset_memories.get_crews", return_value=[] ): yield mock_get_flow @@ -234,9 +196,9 @@ def test_reset_flow_knowledge_no_effect(mock_get_flows, mock_flow, runner): def test_reset_no_crew_or_flow_found(runner): with mock.patch( - "crewai.cli.reset_memories_command.get_crews", return_value=[] + "crewai.utilities.reset_memories.get_crews", return_value=[] ), mock.patch( - "crewai.cli.reset_memories_command.get_flows", return_value=[] + "crewai.utilities.reset_memories.get_flows", return_value=[] ): result = runner.invoke(reset_memories, ["-m"]) assert "No crew or flow found." in result.output @@ -244,9 +206,9 @@ def test_reset_no_crew_or_flow_found(runner): def test_reset_crew_and_flow_memory(mock_crew, mock_flow, runner): with mock.patch( - "crewai.cli.reset_memories_command.get_crews", return_value=[mock_crew] + "crewai.utilities.reset_memories.get_crews", return_value=[mock_crew] ), mock.patch( - "crewai.cli.reset_memories_command.get_flows", return_value=[mock_flow] + "crewai.utilities.reset_memories.get_flows", return_value=[mock_flow] ): result = runner.invoke(reset_memories, ["-m"]) mock_crew.reset_memories.assert_called_once_with(command_type="memory") @@ -260,9 +222,9 @@ def test_reset_flow_memory_none(runner): mock_flow.name = "NoMemFlow" mock_flow.memory = None with mock.patch( - "crewai.cli.reset_memories_command.get_crews", return_value=[] + "crewai.utilities.reset_memories.get_crews", return_value=[] ), mock.patch( - "crewai.cli.reset_memories_command.get_flows", return_value=[mock_flow] + "crewai.utilities.reset_memories.get_flows", return_value=[mock_flow] ): result = runner.invoke(reset_memories, ["-m"]) assert "[Flow (NoMemFlow)] Memory has been reset." in result.output @@ -276,200 +238,3 @@ def test_reset_no_memory_flags(runner): result.output == "Please specify at least one memory type to reset using the appropriate flags.\n" ) - - -def test_version_flag(runner): - result = runner.invoke(version) - - assert result.exit_code == 0 - assert "crewai version:" in result.output - - -def test_version_command(runner): - result = runner.invoke(version) - - assert result.exit_code == 0 - assert "crewai version:" in result.output - - -def test_version_command_with_tools(runner): - result = runner.invoke(version, ["--tools"]) - - assert result.exit_code == 0 - assert "crewai version:" in result.output - assert ( - "crewai tools version:" in result.output - or "crewai tools not installed" in result.output - ) - - -@mock.patch("crewai.cli.cli.evaluate_crew") -def test_test_default_iterations(evaluate_crew, runner): - result = runner.invoke(test) - - evaluate_crew.assert_called_once_with(3, "gpt-4o-mini", trained_agents_file=None) - assert result.exit_code == 0 - assert "Testing the crew for 3 iterations with model gpt-4o-mini" in result.output - - -@mock.patch("crewai.cli.cli.evaluate_crew") -def test_test_custom_iterations(evaluate_crew, runner): - result = runner.invoke(test, ["--n_iterations", "5", "--model", "gpt-4o"]) - - evaluate_crew.assert_called_once_with(5, "gpt-4o", trained_agents_file=None) - assert result.exit_code == 0 - assert "Testing the crew for 5 iterations with model gpt-4o" in result.output - - -@mock.patch("crewai.cli.cli.evaluate_crew") -def test_test_invalid_string_iterations(evaluate_crew, runner): - result = runner.invoke(test, ["--n_iterations", "invalid"]) - - evaluate_crew.assert_not_called() - assert result.exit_code == 2 - assert ( - "Usage: test [OPTIONS]\nTry 'test --help' for help.\n\nError: Invalid value for '-n' / '--n_iterations': 'invalid' is not a valid integer.\n" - in result.output - ) - - -@mock.patch("crewai.cli.cli.AuthenticationCommand") -def test_login(command, runner): - mock_auth = command.return_value - result = runner.invoke(login) - - assert result.exit_code == 0 - mock_auth.login.assert_called_once() - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_create(command, runner): - mock_deploy = command.return_value - result = runner.invoke(deploy_create) - - assert result.exit_code == 0 - mock_deploy.create_crew.assert_called_once() - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_list(command, runner): - mock_deploy = command.return_value - result = runner.invoke(deploy_list) - - assert result.exit_code == 0 - mock_deploy.list_crews.assert_called_once() - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_push(command, runner): - mock_deploy = command.return_value - uuid = "test-uuid" - result = runner.invoke(deploy_push, ["-u", uuid]) - - assert result.exit_code == 0 - mock_deploy.deploy.assert_called_once_with(uuid=uuid, skip_validate=False) - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_push_no_uuid(command, runner): - mock_deploy = command.return_value - result = runner.invoke(deploy_push) - - assert result.exit_code == 0 - mock_deploy.deploy.assert_called_once_with(uuid=None, skip_validate=False) - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_status(command, runner): - mock_deploy = command.return_value - uuid = "test-uuid" - result = runner.invoke(deply_status, ["-u", uuid]) - - assert result.exit_code == 0 - mock_deploy.get_crew_status.assert_called_once_with(uuid=uuid) - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_status_no_uuid(command, runner): - mock_deploy = command.return_value - result = runner.invoke(deply_status) - - assert result.exit_code == 0 - mock_deploy.get_crew_status.assert_called_once_with(uuid=None) - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_logs(command, runner): - mock_deploy = command.return_value - uuid = "test-uuid" - result = runner.invoke(deploy_logs, ["-u", uuid]) - - assert result.exit_code == 0 - mock_deploy.get_crew_logs.assert_called_once_with(uuid=uuid) - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_logs_no_uuid(command, runner): - mock_deploy = command.return_value - result = runner.invoke(deploy_logs) - - assert result.exit_code == 0 - mock_deploy.get_crew_logs.assert_called_once_with(uuid=None) - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_remove(command, runner): - mock_deploy = command.return_value - uuid = "test-uuid" - result = runner.invoke(deploy_remove, ["-u", uuid]) - - assert result.exit_code == 0 - mock_deploy.remove_crew.assert_called_once_with(uuid=uuid) - - -@mock.patch("crewai.cli.cli.DeployCommand") -def test_deploy_remove_no_uuid(command, runner): - mock_deploy = command.return_value - result = runner.invoke(deploy_remove) - - assert result.exit_code == 0 - mock_deploy.remove_crew.assert_called_once_with(uuid=None) - - -@mock.patch("crewai.cli.add_crew_to_flow.create_embedded_crew") -@mock.patch("pathlib.Path.exists", return_value=True) # Mock the existence check -def test_flow_add_crew(mock_path_exists, mock_create_embedded_crew, runner): - crew_name = "new_crew" - result = runner.invoke(flow_add_crew, [crew_name]) - - # Log the output for debugging - print(result.output) - - assert result.exit_code == 0, f"Command failed with output: {result.output}" - assert f"Adding crew {crew_name} to the flow" in result.output - - # Verify that create_embedded_crew was called with the correct arguments - mock_create_embedded_crew.assert_called_once() - call_args, call_kwargs = mock_create_embedded_crew.call_args - assert call_args[0] == crew_name - assert "parent_folder" in call_kwargs - assert isinstance(call_kwargs["parent_folder"], Path) - - -def test_add_crew_to_flow_not_in_root(runner): - # Simulate not being in the root of a flow project - with mock.patch("pathlib.Path.exists", autospec=True) as mock_exists: - # Mock Path.exists to return False when checking for pyproject.toml - def exists_side_effect(self): - if self.name == "pyproject.toml": - return False # Simulate that pyproject.toml does not exist - return True # All other paths exist - - mock_exists.side_effect = exists_side_effect - - result = runner.invoke(flow_add_crew, ["new_crew"]) - - assert result.exit_code != 0 - assert "This command must be run from the root of a flow project." in str( - result.output - ) diff --git a/lib/crewai/tests/cli/test_config.py b/lib/crewai/tests/cli/test_config.py index 4dec94ee3..a07e0971c 100644 --- a/lib/crewai/tests/cli/test_config.py +++ b/lib/crewai/tests/cli/test_config.py @@ -6,13 +6,13 @@ from datetime import datetime, timedelta from pathlib import Path from unittest.mock import MagicMock, patch -from crewai.cli.config import ( +from crewai.settings import ( CLI_SETTINGS_KEYS, DEFAULT_CLI_SETTINGS, USER_SETTINGS_KEYS, Settings, ) -from crewai.cli.shared.token_manager import TokenManager +from crewai_core.token_manager import TokenManager class TestSettings(unittest.TestCase): @@ -69,7 +69,7 @@ class TestSettings(unittest.TestCase): for key in user_settings.keys(): self.assertEqual(getattr(settings, key), None) - @patch("crewai.cli.config.TokenManager") + @patch("crewai_core.settings.TokenManager") def test_reset_settings(self, mock_token_manager): user_settings = {key: f"value_for_{key}" for key in USER_SETTINGS_KEYS} cli_settings = {key: f"value_for_{key}" for key in CLI_SETTINGS_KEYS if key != "oauth2_extra"} diff --git a/lib/crewai/tests/cli/test_constants.py b/lib/crewai/tests/cli/test_constants.py index 013d8ff8c..346875c8f 100644 --- a/lib/crewai/tests/cli/test_constants.py +++ b/lib/crewai/tests/cli/test_constants.py @@ -1,4 +1,4 @@ -from crewai.cli.constants import ENV_VARS, MODELS, PROVIDERS +from crewai.constants import ENV_VARS, MODELS, PROVIDERS def test_huggingface_in_providers(): diff --git a/lib/crewai/tests/cli/test_crew_chat.py b/lib/crewai/tests/cli/test_crew_chat.py index b4498c7f4..89dd8e089 100644 --- a/lib/crewai/tests/cli/test_crew_chat.py +++ b/lib/crewai/tests/cli/test_crew_chat.py @@ -1,8 +1,8 @@ -"""Tests for ``crewai.cli.crew_chat`` startup-safety helpers.""" +"""Tests for ``crewai.utilities.crew_chat`` startup-safety helpers.""" from unittest import mock -from crewai.cli.crew_chat import ( +from crewai.utilities.crew_chat import ( DEFAULT_CREW_DESCRIPTION, DEFAULT_INPUT_DESCRIPTION, generate_crew_chat_inputs, diff --git a/lib/crewai/tests/cli/test_plus_api.py b/lib/crewai/tests/cli/test_plus_api.py index 79baeb733..f38eef9b1 100644 --- a/lib/crewai/tests/cli/test_plus_api.py +++ b/lib/crewai/tests/cli/test_plus_api.py @@ -4,7 +4,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest -from crewai.cli.plus_api import PlusAPI +from crewai.plus_api import PlusAPI class TestPlusAPI(unittest.TestCase): @@ -20,7 +20,7 @@ class TestPlusAPI(unittest.TestCase): self.assertTrue("CrewAI-CLI/" in self.api.headers["User-Agent"]) self.assertTrue(self.api.headers["X-Crewai-Version"]) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_login_to_tool_repository(self, mock_make_request): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -32,7 +32,7 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_login_to_tool_repository_with_user_identifier(self, mock_make_request): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -60,8 +60,8 @@ class TestPlusAPI(unittest.TestCase): **kwargs, ) - @patch("crewai.cli.plus_api.Settings") - @patch("crewai.cli.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_login_to_tool_repository_with_org_uuid( self, mock_client_class, mock_settings_class ): @@ -83,7 +83,7 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_get_tool(self, mock_make_request): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -94,8 +94,8 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.Settings") - @patch("crewai.cli.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class): mock_settings = MagicMock() mock_settings.org_uuid = self.org_uuid @@ -115,7 +115,7 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_publish_tool(self, mock_make_request): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -143,8 +143,8 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.Settings") - @patch("crewai.cli.plus_api.httpx.Client") + @patch("crewai_core.plus_api.Settings") + @patch("crewai_core.plus_api.httpx.Client") def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class): mock_settings = MagicMock() mock_settings.org_uuid = self.org_uuid @@ -182,7 +182,7 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_publish_tool_without_description(self, mock_make_request): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -210,7 +210,7 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_publish_tool_with_tools_metadata(self, mock_make_request): mock_response = MagicMock() mock_make_request.return_value = mock_response @@ -251,7 +251,7 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.httpx.Client") + @patch("crewai_core.plus_api.httpx.Client") def test_make_request(self, mock_client_class): mock_client_instance = MagicMock() mock_response = MagicMock() @@ -266,35 +266,35 @@ class TestPlusAPI(unittest.TestCase): ) self.assertEqual(response, mock_response) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_deploy_by_name(self, mock_make_request): self.api.deploy_by_name("test_project") mock_make_request.assert_called_once_with( "POST", "/crewai_plus/api/v1/crews/by-name/test_project/deploy" ) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_deploy_by_uuid(self, mock_make_request): self.api.deploy_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "POST", "/crewai_plus/api/v1/crews/test_uuid/deploy" ) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_crew_status_by_name(self, mock_make_request): self.api.crew_status_by_name("test_project") mock_make_request.assert_called_once_with( "GET", "/crewai_plus/api/v1/crews/by-name/test_project/status" ) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_crew_status_by_uuid(self, mock_make_request): self.api.crew_status_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "GET", "/crewai_plus/api/v1/crews/test_uuid/status" ) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_crew_by_name(self, mock_make_request): self.api.crew_by_name("test_project") mock_make_request.assert_called_once_with( @@ -306,7 +306,7 @@ class TestPlusAPI(unittest.TestCase): "GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/custom_log" ) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_crew_by_uuid(self, mock_make_request): self.api.crew_by_uuid("test_uuid") mock_make_request.assert_called_once_with( @@ -318,26 +318,26 @@ class TestPlusAPI(unittest.TestCase): "GET", "/crewai_plus/api/v1/crews/test_uuid/logs/custom_log" ) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_delete_crew_by_name(self, mock_make_request): self.api.delete_crew_by_name("test_project") mock_make_request.assert_called_once_with( "DELETE", "/crewai_plus/api/v1/crews/by-name/test_project" ) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_delete_crew_by_uuid(self, mock_make_request): self.api.delete_crew_by_uuid("test_uuid") mock_make_request.assert_called_once_with( "DELETE", "/crewai_plus/api/v1/crews/test_uuid" ) - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_list_crews(self, mock_make_request): self.api.list_crews() mock_make_request.assert_called_once_with("GET", "/crewai_plus/api/v1/crews") - @patch("crewai.cli.plus_api.PlusAPI._make_request") + @patch("crewai_core.plus_api.PlusAPI._make_request") def test_create_crew(self, mock_make_request): payload = {"name": "test_crew"} self.api.create_crew(payload) @@ -345,7 +345,7 @@ class TestPlusAPI(unittest.TestCase): "POST", "/crewai_plus/api/v1/crews", json=payload ) - @patch("crewai.cli.plus_api.Settings") + @patch("crewai_core.plus_api.Settings") @patch.dict(os.environ, {"CREWAI_PLUS_URL": ""}) def test_custom_base_url(self, mock_settings_class): mock_settings = MagicMock() @@ -386,7 +386,7 @@ async def test_get_agent(mock_async_client_class): @pytest.mark.asyncio @patch("httpx.AsyncClient") -@patch("crewai.cli.plus_api.Settings") +@patch("crewai_core.plus_api.Settings") async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class): org_uuid = "test-org-uuid" mock_settings = MagicMock() diff --git a/lib/crewai/tests/cli/test_replay_from_task.py b/lib/crewai/tests/cli/test_replay_from_task.py index c1752c4f1..639413733 100644 --- a/lib/crewai/tests/cli/test_replay_from_task.py +++ b/lib/crewai/tests/cli/test_replay_from_task.py @@ -6,8 +6,8 @@ from unittest import mock from click.testing import CliRunner import pytest -from crewai.cli import replay_from_task -from crewai.cli.cli import replay +from crewai_cli import replay_from_task +from crewai_cli.cli import replay @pytest.fixture @@ -15,7 +15,7 @@ def runner() -> CliRunner: return CliRunner() -@mock.patch("crewai.cli.cli.replay_task_command") +@mock.patch("crewai_cli.cli.replay_task_command") def test_replay_passes_filename(replay_task_command_mock: mock.Mock, runner: CliRunner) -> None: result = runner.invoke(replay, ["-t", "abc123", "-f", "my_custom.pkl"]) @@ -25,7 +25,7 @@ def test_replay_passes_filename(replay_task_command_mock: mock.Mock, runner: Cli assert result.exit_code == 0 -@mock.patch("crewai.cli.cli.replay_task_command") +@mock.patch("crewai_cli.cli.replay_task_command") def test_replay_without_filename_passes_none( replay_task_command_mock: mock.Mock, runner: CliRunner ) -> None: @@ -37,7 +37,7 @@ def test_replay_without_filename_passes_none( assert result.exit_code == 0 -@mock.patch("crewai.cli.replay_from_task.subprocess.run") +@mock.patch("crewai_cli.replay_from_task.subprocess.run") def test_replay_task_command_sets_env_var(mock_subprocess_run: mock.Mock) -> None: mock_subprocess_run.return_value = subprocess.CompletedProcess( args=["uv", "run", "replay", "abc123"], returncode=0 @@ -48,7 +48,7 @@ def test_replay_task_command_sets_env_var(mock_subprocess_run: mock.Mock) -> Non assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom.pkl" -@mock.patch("crewai.cli.replay_from_task.subprocess.run") +@mock.patch("crewai_cli.replay_from_task.subprocess.run") def test_replay_task_command_omits_env_var_without_filename( mock_subprocess_run: mock.Mock, ) -> None: diff --git a/lib/crewai/tests/cli/test_run_crew.py b/lib/crewai/tests/cli/test_run_crew.py index 7bc803592..077741193 100644 --- a/lib/crewai/tests/cli/test_run_crew.py +++ b/lib/crewai/tests/cli/test_run_crew.py @@ -5,8 +5,8 @@ from unittest import mock from click.testing import CliRunner import pytest -from crewai.cli.cli import run -from crewai.cli.run_crew import CrewType, execute_command +from crewai_cli.cli import run +from crewai_cli.run_crew import CrewType, execute_command @pytest.fixture @@ -14,7 +14,7 @@ def runner() -> CliRunner: return CliRunner() -@mock.patch("crewai.cli.cli.run_crew") +@mock.patch("crewai_cli.cli.run_crew") def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRunner) -> None: result = runner.invoke(run, ["-f", "my_custom_trained.pkl"]) @@ -22,7 +22,7 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu assert result.exit_code == 0 -@mock.patch("crewai.cli.cli.run_crew") +@mock.patch("crewai_cli.cli.run_crew") def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliRunner) -> None: result = runner.invoke(run) @@ -30,9 +30,9 @@ def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliR assert result.exit_code == 0 -@mock.patch("crewai.cli.run_crew.subprocess.run") +@mock.patch("crewai_cli.run_crew.subprocess.run") @mock.patch( - "crewai.cli.run_crew.build_env_with_all_tool_credentials", + "crewai_cli.run_crew.build_env_with_all_tool_credentials", return_value={"EXISTING": "value"}, ) def test_execute_command_sets_env_var_when_filename_provided( @@ -45,9 +45,9 @@ def test_execute_command_sets_env_var_when_filename_provided( assert kwargs["env"]["EXISTING"] == "value" -@mock.patch("crewai.cli.run_crew.subprocess.run") +@mock.patch("crewai_cli.run_crew.subprocess.run") @mock.patch( - "crewai.cli.run_crew.build_env_with_all_tool_credentials", + "crewai_cli.run_crew.build_env_with_all_tool_credentials", return_value={"EXISTING": "value"}, ) def test_execute_command_omits_env_var_when_filename_absent( diff --git a/lib/crewai/tests/cli/test_token_manager.py b/lib/crewai/tests/cli/test_token_manager.py index 5d7fc5790..791de53c7 100644 --- a/lib/crewai/tests/cli/test_token_manager.py +++ b/lib/crewai/tests/cli/test_token_manager.py @@ -10,20 +10,20 @@ from unittest.mock import patch from cryptography.fernet import Fernet -from crewai.cli.shared.token_manager import TokenManager +from crewai_core.token_manager import TokenManager class TestTokenManager(unittest.TestCase): """Test cases for TokenManager.""" - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def setUp(self, mock_get_key: unittest.mock.MagicMock) -> None: """Set up test fixtures.""" mock_get_key.return_value = Fernet.generate_key() self.token_manager = TokenManager() - @patch("crewai.cli.shared.token_manager.TokenManager._read_secure_file") - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_get_or_create_key_existing( self, mock_get_or_create: unittest.mock.MagicMock, @@ -45,7 +45,7 @@ class TestTokenManager(unittest.TestCase): with ( patch.object(self.token_manager, "_read_secure_file", return_value=None) as mock_read, patch.object(self.token_manager, "_atomic_create_secure_file", return_value=True) as mock_atomic_create, - patch("crewai.cli.shared.token_manager.Fernet.generate_key", return_value=mock_key) as mock_generate, + patch("crewai_core.token_manager.Fernet.generate_key", return_value=mock_key) as mock_generate, ): result = self.token_manager._get_or_create_key() @@ -62,14 +62,14 @@ class TestTokenManager(unittest.TestCase): with ( patch.object(self.token_manager, "_read_secure_file", side_effect=[None, their_key]) as mock_read, patch.object(self.token_manager, "_atomic_create_secure_file", return_value=False) as mock_atomic_create, - patch("crewai.cli.shared.token_manager.Fernet.generate_key", return_value=our_key), + patch("crewai_core.token_manager.Fernet.generate_key", return_value=our_key), ): result = self.token_manager._get_or_create_key() self.assertEqual(result, their_key) self.assertEqual(mock_read.call_count, 2) - @patch("crewai.cli.shared.token_manager.TokenManager._atomic_write_secure_file") + @patch("crewai_core.token_manager.TokenManager._atomic_write_secure_file") def test_save_tokens( self, mock_write: unittest.mock.MagicMock ) -> None: @@ -88,7 +88,7 @@ class TestTokenManager(unittest.TestCase): expiration = datetime.fromisoformat(data["expiration"]) self.assertEqual(expiration, datetime.fromtimestamp(expires_at)) - @patch("crewai.cli.shared.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_valid( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -103,7 +103,7 @@ class TestTokenManager(unittest.TestCase): self.assertEqual(result, access_token) - @patch("crewai.cli.shared.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_expired( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -118,7 +118,7 @@ class TestTokenManager(unittest.TestCase): self.assertIsNone(result) - @patch("crewai.cli.shared.token_manager.TokenManager._read_secure_file") + @patch("crewai_core.token_manager.TokenManager._read_secure_file") def test_get_token_not_found( self, mock_read: unittest.mock.MagicMock ) -> None: @@ -129,7 +129,7 @@ class TestTokenManager(unittest.TestCase): self.assertIsNone(result) - @patch("crewai.cli.shared.token_manager.TokenManager._delete_secure_file") + @patch("crewai_core.token_manager.TokenManager._delete_secure_file") def test_clear_tokens( self, mock_delete: unittest.mock.MagicMock ) -> None: @@ -159,7 +159,7 @@ class TestAtomicFileOperations(unittest.TestCase): import shutil shutil.rmtree(self.temp_dir, ignore_errors=True) - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_create_new_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -175,7 +175,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"content") self.assertEqual(file_path.stat().st_mode & 0o777, 0o600) - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_create_existing_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -192,7 +192,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertFalse(result) self.assertEqual(file_path.read_bytes(), b"original") - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_new_file( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -207,7 +207,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"content") self.assertEqual(file_path.stat().st_mode & 0o777, 0o600) - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_overwrites( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -222,7 +222,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(file_path.read_bytes(), b"new content") - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_atomic_write_no_temp_file_on_success( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -236,7 +236,7 @@ class TestAtomicFileOperations(unittest.TestCase): temp_files = list(Path(self.temp_dir).glob(".test.txt.*")) self.assertEqual(len(temp_files), 0) - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_read_secure_file_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -251,7 +251,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertEqual(result, b"content") - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_read_secure_file_not_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -263,7 +263,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertIsNone(result) - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_delete_secure_file_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: @@ -278,7 +278,7 @@ class TestAtomicFileOperations(unittest.TestCase): self.assertFalse(file_path.exists()) - @patch("crewai.cli.shared.token_manager.TokenManager._get_or_create_key") + @patch("crewai_core.token_manager.TokenManager._get_or_create_key") def test_delete_secure_file_not_exists( self, mock_get_key: unittest.mock.MagicMock ) -> None: diff --git a/lib/crewai/tests/cli/test_utils.py b/lib/crewai/tests/cli/test_utils.py index fc006a417..3016ba289 100644 --- a/lib/crewai/tests/cli/test_utils.py +++ b/lib/crewai/tests/cli/test_utils.py @@ -1,26 +1,9 @@ import os -import shutil import tempfile from pathlib import Path import pytest -from crewai.cli import utils - - -@pytest.fixture -def temp_tree(): - root_dir = tempfile.mkdtemp() - - create_file(os.path.join(root_dir, "file1.txt"), "Hello, world!") - create_file(os.path.join(root_dir, "file2.txt"), "Another file") - os.mkdir(os.path.join(root_dir, "empty_dir")) - nested_dir = os.path.join(root_dir, "nested_dir") - os.mkdir(nested_dir) - create_file(os.path.join(nested_dir, "nested_file.txt"), "Nested content") - - yield root_dir - - shutil.rmtree(root_dir) +from crewai.utilities import project_utils as utils def create_file(path, content): @@ -28,80 +11,6 @@ def create_file(path, content): f.write(content) -def test_tree_find_and_replace_file_content(temp_tree): - utils.tree_find_and_replace(temp_tree, "world", "universe") - with open(os.path.join(temp_tree, "file1.txt"), "r") as f: - assert f.read() == "Hello, universe!" - - -def test_tree_find_and_replace_file_name(temp_tree): - old_path = os.path.join(temp_tree, "file2.txt") - new_path = os.path.join(temp_tree, "file2_renamed.txt") - os.rename(old_path, new_path) - utils.tree_find_and_replace(temp_tree, "renamed", "modified") - assert os.path.exists(os.path.join(temp_tree, "file2_modified.txt")) - assert not os.path.exists(new_path) - - -def test_tree_find_and_replace_directory_name(temp_tree): - utils.tree_find_and_replace(temp_tree, "empty", "renamed") - assert os.path.exists(os.path.join(temp_tree, "renamed_dir")) - assert not os.path.exists(os.path.join(temp_tree, "empty_dir")) - - -def test_tree_find_and_replace_nested_content(temp_tree): - utils.tree_find_and_replace(temp_tree, "Nested", "Updated") - with open(os.path.join(temp_tree, "nested_dir", "nested_file.txt"), "r") as f: - assert f.read() == "Updated content" - - -def test_tree_find_and_replace_no_matches(temp_tree): - utils.tree_find_and_replace(temp_tree, "nonexistent", "replacement") - assert set(os.listdir(temp_tree)) == { - "file1.txt", - "file2.txt", - "empty_dir", - "nested_dir", - } - - -def test_tree_copy_full_structure(temp_tree): - dest_dir = tempfile.mkdtemp() - try: - utils.tree_copy(temp_tree, dest_dir) - assert set(os.listdir(dest_dir)) == set(os.listdir(temp_tree)) - assert os.path.isfile(os.path.join(dest_dir, "file1.txt")) - assert os.path.isfile(os.path.join(dest_dir, "file2.txt")) - assert os.path.isdir(os.path.join(dest_dir, "empty_dir")) - assert os.path.isdir(os.path.join(dest_dir, "nested_dir")) - assert os.path.isfile(os.path.join(dest_dir, "nested_dir", "nested_file.txt")) - finally: - shutil.rmtree(dest_dir) - - -def test_tree_copy_preserve_content(temp_tree): - dest_dir = tempfile.mkdtemp() - try: - utils.tree_copy(temp_tree, dest_dir) - with open(os.path.join(dest_dir, "file1.txt"), "r") as f: - assert f.read() == "Hello, world!" - with open(os.path.join(dest_dir, "nested_dir", "nested_file.txt"), "r") as f: - assert f.read() == "Nested content" - finally: - shutil.rmtree(dest_dir) - - -def test_tree_copy_to_existing_directory(temp_tree): - dest_dir = tempfile.mkdtemp() - try: - create_file(os.path.join(dest_dir, "existing_file.txt"), "I was here first") - utils.tree_copy(temp_tree, dest_dir) - assert os.path.isfile(os.path.join(dest_dir, "existing_file.txt")) - assert os.path.isfile(os.path.join(dest_dir, "file1.txt")) - finally: - shutil.rmtree(dest_dir) - - @pytest.fixture def temp_project_dir(): """Create a temporary directory for testing tool extraction.""" diff --git a/lib/crewai/tests/cli/test_version.py b/lib/crewai/tests/cli/test_version.py index 4e53ea923..c5ada8058 100644 --- a/lib/crewai/tests/cli/test_version.py +++ b/lib/crewai/tests/cli/test_version.py @@ -6,16 +6,18 @@ from pathlib import Path from unittest.mock import MagicMock, patch from crewai import __version__ -from crewai.cli.version import ( - _find_latest_non_yanked_version, - _get_cache_file, - _is_cache_valid, - _is_version_yanked, +from crewai.version import ( get_crewai_version, get_latest_version_from_pypi, is_current_version_yanked, is_newer_version_available, ) +from crewai_core.version import ( + _find_latest_non_yanked_version, + _get_cache_file, + _is_cache_valid, + _is_version_yanked, +) def test_dynamic_versioning_consistency() -> None: @@ -60,8 +62,8 @@ class TestVersionChecking: cache_data = {"version": "1.0.0"} assert _is_cache_valid(cache_data) is False - @patch("crewai.cli.version.Path.exists") - @patch("crewai.cli.version.request.urlopen") + @patch("crewai_core.version.Path.exists") + @patch("crewai_core.version.request.urlopen") def test_get_latest_version_from_pypi_success( self, mock_urlopen: MagicMock, mock_exists: MagicMock ) -> None: @@ -82,8 +84,8 @@ class TestVersionChecking: version = get_latest_version_from_pypi() assert version == "2.0.0" - @patch("crewai.cli.version.Path.exists") - @patch("crewai.cli.version.request.urlopen") + @patch("crewai_core.version.Path.exists") + @patch("crewai_core.version.request.urlopen") def test_get_latest_version_from_pypi_failure( self, mock_urlopen: MagicMock, mock_exists: MagicMock ) -> None: @@ -97,8 +99,8 @@ class TestVersionChecking: version = get_latest_version_from_pypi() assert version is None - @patch("crewai.cli.version.get_crewai_version") - @patch("crewai.cli.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_true( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -111,8 +113,8 @@ class TestVersionChecking: assert current == "1.0.0" assert latest == "2.0.0" - @patch("crewai.cli.version.get_crewai_version") - @patch("crewai.cli.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_false( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -125,8 +127,8 @@ class TestVersionChecking: assert current == "2.0.0" assert latest == "2.0.0" - @patch("crewai.cli.version.get_crewai_version") - @patch("crewai.cli.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version.get_latest_version_from_pypi") def test_is_newer_version_available_with_none_latest( self, mock_latest: MagicMock, mock_current: MagicMock ) -> None: @@ -260,8 +262,8 @@ class TestIsVersionYanked: class TestIsCurrentVersionYanked: """Test is_current_version_yanked public function.""" - @patch("crewai.cli.version.get_crewai_version") - @patch("crewai.cli.version._get_cache_file") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_reads_from_valid_cache( self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path ) -> None: @@ -282,8 +284,8 @@ class TestIsCurrentVersionYanked: assert is_yanked is True assert reason == "bad release" - @patch("crewai.cli.version.get_crewai_version") - @patch("crewai.cli.version._get_cache_file") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_not_yanked_from_cache( self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path ) -> None: @@ -304,9 +306,9 @@ class TestIsCurrentVersionYanked: assert is_yanked is False assert reason == "" - @patch("crewai.cli.version.get_latest_version_from_pypi") - @patch("crewai.cli.version.get_crewai_version") - @patch("crewai.cli.version._get_cache_file") + @patch("crewai_core.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_triggers_fetch_on_stale_cache( self, mock_cache_file: MagicMock, @@ -346,9 +348,9 @@ class TestIsCurrentVersionYanked: assert is_yanked is False mock_fetch.assert_called_once() - @patch("crewai.cli.version.get_latest_version_from_pypi") - @patch("crewai.cli.version.get_crewai_version") - @patch("crewai.cli.version._get_cache_file") + @patch("crewai_core.version.get_latest_version_from_pypi") + @patch("crewai_core.version.get_crewai_version") + @patch("crewai_core.version._get_cache_file") def test_returns_false_on_fetch_failure( self, mock_cache_file: MagicMock, diff --git a/lib/crewai/tests/llms/openai/test_openai.py b/lib/crewai/tests/llms/openai/test_openai.py index 5a2a6a299..746729edb 100644 --- a/lib/crewai/tests/llms/openai/test_openai.py +++ b/lib/crewai/tests/llms/openai/test_openai.py @@ -11,7 +11,6 @@ from crewai.llms.providers.openai.completion import OpenAICompletion, ResponsesA from crewai.crew import Crew from crewai.agent import Agent from crewai.task import Task -from crewai.cli.constants import DEFAULT_LLM_MODEL def test_openai_completion_is_used_when_openai_provider(): """ diff --git a/lib/crewai/tests/mcp/test_amp_mcp.py b/lib/crewai/tests/mcp/test_amp_mcp.py index f13484a8d..5b86a525d 100644 --- a/lib/crewai/tests/mcp/test_amp_mcp.py +++ b/lib/crewai/tests/mcp/test_amp_mcp.py @@ -102,7 +102,7 @@ class TestBuildMCPConfigFromDict: class TestFetchAmpMCPConfigs: - @patch("crewai.cli.plus_api.PlusAPI") + @patch("crewai.plus_api.PlusAPI") @patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", return_value="test-api-key") def test_fetches_configs_successfully(self, mock_get_token, mock_plus_api_class, resolver): mock_response = MagicMock() @@ -133,7 +133,7 @@ class TestFetchAmpMCPConfigs: mock_plus_api_class.assert_called_once_with(api_key="test-api-key") mock_plus_api.get_mcp_configs.assert_called_once_with(["notion", "github"]) - @patch("crewai.cli.plus_api.PlusAPI") + @patch("crewai.plus_api.PlusAPI") @patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", return_value="test-api-key") def test_omits_missing_slugs(self, mock_get_token, mock_plus_api_class, resolver): mock_response = MagicMock() @@ -150,7 +150,7 @@ class TestFetchAmpMCPConfigs: assert "notion" in result assert "missing-server" not in result - @patch("crewai.cli.plus_api.PlusAPI") + @patch("crewai.plus_api.PlusAPI") @patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", return_value="test-api-key") def test_returns_empty_on_http_error(self, mock_get_token, mock_plus_api_class, resolver): mock_response = MagicMock() @@ -163,7 +163,7 @@ class TestFetchAmpMCPConfigs: assert result == {} - @patch("crewai.cli.plus_api.PlusAPI") + @patch("crewai.plus_api.PlusAPI") @patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", return_value="test-api-key") def test_returns_empty_on_network_error(self, mock_get_token, mock_plus_api_class, resolver): import httpx diff --git a/lib/crewai/tests/memory/test_unified_memory.py b/lib/crewai/tests/memory/test_unified_memory.py index be52e6db5..3c9678b6f 100644 --- a/lib/crewai/tests/memory/test_unified_memory.py +++ b/lib/crewai/tests/memory/test_unified_memory.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock import pytest -from crewai.utilities.printer import Printer +from crewai_core.printer import Printer from crewai.memory.types import ( MemoryConfig, MemoryMatch, diff --git a/lib/crewai/tests/test_checkpoint.py b/lib/crewai/tests/test_checkpoint.py index 525e3ca3b..369db1d6c 100644 --- a/lib/crewai/tests/test_checkpoint.py +++ b/lib/crewai/tests/test_checkpoint.py @@ -206,7 +206,7 @@ class TestRuntimeStateLineage: assert state._branch == "main" def test_serialize_includes_version(self) -> None: - from crewai.utilities.version import get_crewai_version + from crewai_core.version import get_crewai_version state = self._make_state() dumped = json.loads(state.model_dump_json()) diff --git a/lib/crewai/tests/test_checkpoint_cli.py b/lib/crewai/tests/test_checkpoint_cli.py index aa1188336..b0b56b3c6 100644 --- a/lib/crewai/tests/test_checkpoint_cli.py +++ b/lib/crewai/tests/test_checkpoint_cli.py @@ -12,7 +12,7 @@ from typing import Any from unittest.mock import MagicMock, patch import pytest -from crewai.cli.checkpoint_cli import ( +from crewai_cli.checkpoint_cli import ( _parse_checkpoint_json, _parse_duration, _prune_json, diff --git a/lib/crewai/tests/tracing/test_tracing.py b/lib/crewai/tests/tracing/test_tracing.py index 38bb060bd..723904a8f 100644 --- a/lib/crewai/tests/tracing/test_tracing.py +++ b/lib/crewai/tests/tracing/test_tracing.py @@ -36,7 +36,7 @@ class TestTraceListenerSetup: # Need to patch all the places where get_auth_token is imported/used with ( patch( - "crewai.cli.authentication.token.get_auth_token", + "crewai.auth.token.get_auth_token", return_value="mock_token_12345", ), patch( diff --git a/lib/crewai/tests/utilities/test_llm_utils.py b/lib/crewai/tests/utilities/test_llm_utils.py index a32fdcbc9..5b4aaeef9 100644 --- a/lib/crewai/tests/utilities/test_llm_utils.py +++ b/lib/crewai/tests/utilities/test_llm_utils.py @@ -2,7 +2,7 @@ import os from typing import Any from unittest.mock import patch -from crewai.cli.constants import DEFAULT_LLM_MODEL +from crewai.constants import DEFAULT_LLM_MODEL from crewai.llm import LLM from crewai.llms.base_llm import BaseLLM from crewai.utilities.llm_utils import create_llm diff --git a/lib/crewai/tests/utilities/test_lock_store.py b/lib/crewai/tests/utilities/test_lock_store.py index 8e0e6babc..5ce2d8107 100644 --- a/lib/crewai/tests/utilities/test_lock_store.py +++ b/lib/crewai/tests/utilities/test_lock_store.py @@ -11,8 +11,8 @@ from unittest import mock import pytest -import crewai.utilities.lock_store as lock_store -from crewai.utilities.lock_store import lock +import crewai_core.lock_store as lock_store +from crewai_core.lock_store import lock @pytest.fixture(autouse=True) diff --git a/pyproject.toml b/pyproject.toml index b15b4ac14..fe3b21414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dev = [ "types-psycopg2==2.9.21.20251012", "types-pymysql==1.1.0.20250916", "types-aiofiles~=25.1.0", + "types-redis~=4.6", "commitizen>=4.13.9", "pip-audit==2.9.0", ] @@ -38,8 +39,10 @@ dev = [ src = ["lib/*"] extend-exclude = [ "lib/crewai/src/crewai/cli/templates", + "lib/cli/src/crewai_cli/templates", "lib/crewai/tests/", "lib/crewai-tools/tests/", + "lib/cli/tests/", ] respect-gitignore = true force-exclude = true @@ -67,6 +70,7 @@ extend-select = [ "TID", # flake8-tidy-imports (import best practices) "ASYNC", # async/await best practices "RET", # flake8-return (return improvements) + "SIM118", # use `key in dict` instead of `key in dict.keys()` "UP006", # use collections.abc "UP007", # use X | Y for unions "UP035", # use dict/list instead of typing.Dict/List @@ -108,6 +112,8 @@ ignore-decorators = ["typing.overload"] "lib/crewai/tests/**/*.py" = ["S101", "RET504", "S105", "S106"] # Allow assert statements, unnecessary assignments, and hardcoded passwords in tests "lib/crewai-tools/tests/**/*.py" = ["S101", "RET504", "S105", "S106", "RUF012", "N818", "E402", "RUF043", "S110", "B017"] # Allow various test-specific patterns "lib/crewai-files/tests/**/*.py" = ["S101", "RET504", "S105", "S106", "B017", "F841"] # Allow assert statements and blind exception assertions in tests +"lib/cli/tests/**/*.py" = ["S101", "RET504", "S105", "S106"] # Allow assert statements in tests +"lib/crewai-core/tests/**/*.py" = ["S101", "RET504", "S105", "S106"] # Allow assert statements in tests "lib/devtools/tests/**/*.py" = ["S101"] @@ -121,12 +127,12 @@ warn_return_any = true show_error_codes = true warn_unused_ignores = true python_version = "3.12" -exclude = "(?x)(^lib/crewai/src/crewai/cli/templates/|^lib/crewai/tests/|^lib/crewai-tools/tests/|^lib/crewai-files/tests/)" +exclude = "(?x)(^lib/crewai/src/crewai/cli/templates/|^lib/cli/src/crewai_cli/templates/|^lib/crewai/tests/|^lib/crewai-tools/tests/|^lib/crewai-files/tests/|^lib/cli/tests/|^lib/devtools/tests/)" plugins = ["pydantic.mypy"] [tool.bandit] -exclude_dirs = ["lib/crewai/src/crewai/cli/templates"] +exclude_dirs = ["lib/crewai/src/crewai/cli/templates", "lib/cli/src/crewai_cli/templates"] [tool.pytest.ini_options] @@ -137,6 +143,8 @@ testpaths = [ "lib/crewai/tests", "lib/crewai-tools/tests", "lib/crewai-files/tests", + "lib/cli/tests", + "lib/crewai-core/tests", ] asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" @@ -204,6 +212,8 @@ members = [ "lib/crewai-tools", "lib/devtools", "lib/crewai-files", + "lib/cli", + "lib/crewai-core", ] @@ -212,3 +222,5 @@ crewai = { workspace = true } crewai-tools = { workspace = true } crewai-devtools = { workspace = true } crewai-files = { workspace = true } +crewai-cli = { workspace = true } +crewai-core = { workspace = true } diff --git a/uv.lock b/uv.lock index 5101cea49..f4d0b2634 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,8 @@ exclude-newer = "2026-04-27T16:00:00Z" [manifest] members = [ "crewai", + "crewai-cli", + "crewai-core", "crewai-devtools", "crewai-files", "crewai-tools", @@ -61,6 +63,7 @@ dev = [ { name = "types-psycopg2", specifier = "==2.9.21.20251012" }, { name = "types-pymysql", specifier = "==1.1.0.20250916" }, { name = "types-pyyaml", specifier = "==6.0.*" }, + { name = "types-redis", specifier = "~=4.6" }, { name = "types-regex", specifier = "==2026.1.15.*" }, { name = "types-requests", specifier = "~=2.31.0.6" }, { name = "vcrpy", specifier = "==7.0.0" }, @@ -1279,6 +1282,8 @@ dependencies = [ { name = "appdirs" }, { name = "chromadb" }, { name = "click" }, + { name = "crewai-cli" }, + { name = "crewai-core" }, { name = "httpx" }, { name = "instructor" }, { name = "json-repair" }, @@ -1303,7 +1308,6 @@ dependencies = [ { name = "tokenizers" }, { name = "tomli" }, { name = "tomli-w" }, - { name = "uv" }, ] [package.optional-dependencies] @@ -1382,6 +1386,8 @@ requires-dist = [ { name = "boto3", marker = "extra == 'bedrock'", specifier = "~=1.42.79" }, { name = "chromadb", specifier = "~=1.1.0" }, { name = "click", specifier = "~=8.1.7" }, + { name = "crewai-cli", editable = "lib/cli" }, + { name = "crewai-core", editable = "lib/crewai-core" }, { name = "crewai-files", marker = "extra == 'file-processing'", editable = "lib/crewai-files" }, { name = "crewai-tools", marker = "extra == 'tools'", editable = "lib/crewai-tools" }, { name = "docling", marker = "extra == 'docling'", specifier = "~=2.84.0" }, @@ -1420,11 +1426,82 @@ requires-dist = [ { name = "tokenizers", specifier = ">=0.21,<1" }, { name = "tomli", specifier = "~=2.0.2" }, { name = "tomli-w", specifier = "~=1.1.0" }, - { name = "uv", specifier = "~=0.11.6" }, { name = "voyageai", marker = "extra == 'voyageai'", specifier = "~=0.3.5" }, ] provides-extras = ["a2a", "anthropic", "aws", "azure-ai-inference", "bedrock", "docling", "embeddings", "file-processing", "google-genai", "litellm", "mem0", "openpyxl", "pandas", "qdrant", "qdrant-edge", "tools", "voyageai", "watson"] +[[package]] +name = "crewai-cli" +source = { editable = "lib/cli" } +dependencies = [ + { name = "appdirs" }, + { name = "click" }, + { name = "crewai-core" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "appdirs", specifier = "~=1.4.4" }, + { name = "click", specifier = "~=8.1.7" }, + { name = "crewai-core", editable = "lib/crewai-core" }, + { name = "cryptography", specifier = ">=42.0" }, + { name = "httpx", specifier = "~=0.28.1" }, + { name = "packaging", specifier = ">=23.0" }, + { name = "pydantic", specifier = ">=2.11.9,<2.13" }, + { name = "pydantic-settings", specifier = "~=2.10.1" }, + { name = "pyjwt", specifier = ">=2.9.0,<3" }, + { name = "python-dotenv", specifier = ">=1.2.2,<2" }, + { name = "rich", specifier = ">=13.7.1" }, + { name = "tomli", specifier = "~=2.0.2" }, + { name = "tomli-w", specifier = "~=1.1.0" }, + { name = "uv", specifier = "~=0.11.6" }, +] + +[[package]] +name = "crewai-core" +source = { editable = "lib/crewai-core" } +dependencies = [ + { name = "appdirs" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "portalocker" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "rich" }, + { name = "tomli" }, +] + +[package.metadata] +requires-dist = [ + { name = "appdirs", specifier = "~=1.4.4" }, + { name = "cryptography", specifier = ">=42.0" }, + { name = "httpx", specifier = "~=0.28.1" }, + { name = "opentelemetry-api", specifier = "~=1.34.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" }, + { name = "opentelemetry-sdk", specifier = "~=1.34.0" }, + { name = "packaging", specifier = ">=23.0" }, + { name = "portalocker", specifier = "~=2.7.0" }, + { name = "pydantic", specifier = ">=2.11.9,<2.13" }, + { name = "pyjwt", specifier = ">=2.9.0,<3" }, + { name = "rich", specifier = ">=13.7.1" }, + { name = "tomli", specifier = "~=2.0.2" }, +] + [[package]] name = "crewai-devtools" source = { editable = "lib/devtools" } @@ -8972,6 +9049,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/e5/47a573bbbd0a790f8f9fe452f7188ea72b212d21c9be57d5fc0cbc442075/types_awscrt-0.31.3-py3-none-any.whl", hash = "sha256:e5ce65a00a2ab4f35eacc1e3d700d792338d56e4823ee7b4dbe017f94cfc4458", size = 43340, upload-time = "2026-03-08T02:31:13.38Z" }, ] +[[package]] +name = "types-cffi" +version = "2.0.0.20260408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/67/eb4ef3408fdc0b4e5af38b30c0e6ad4663b41bdae9fb85a9f09a8db61a99/types_cffi-2.0.0.20260408.tar.gz", hash = "sha256:aa8b9c456ab715c079fc655929811f21f331bfb940f4a821987c581bf4e36230", size = 17541, upload-time = "2026-04-08T04:36:03.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/a3/7fbd93ededcc7c77e9e5948b9794161733ebdbf618a27965b1bea0e728a4/types_cffi-2.0.0.20260408-py3-none-any.whl", hash = "sha256:68bd296742b4ff7c0afe3547f50bd0acc55416ecf322ffefd2b7344ef6388a42", size = 20101, upload-time = "2026-04-08T04:36:02.995Z" }, +] + [[package]] name = "types-psycopg2" version = "2.9.21.20251012" @@ -8990,6 +9079,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/eb/a225e32a6e7b196af67ab2f1b07363595f63255374cc3b88bfdab53b4ee8/types_pymysql-1.1.0.20250916-py3-none-any.whl", hash = "sha256:873eb9836bb5e3de4368cc7010ca72775f86e9692a5c7810f8c7f48da082e55b", size = 23063, upload-time = "2025-09-16T02:49:20.933Z" }, ] +[[package]] +name = "types-pyopenssl" +version = "24.1.0.20240722" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "types-cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/29/47a346550fd2020dac9a7a6d033ea03fccb92fa47c726056618cc889745e/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", size = 8458, upload-time = "2024-07-22T02:32:22.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/05/c868a850b6fbb79c26f5f299b768ee0adc1f9816d3461dcf4287916f655b/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54", size = 7499, upload-time = "2024-07-22T02:32:21.232Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260408" @@ -8999,6 +9101,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, ] +[[package]] +name = "types-redis" +version = "4.6.0.20241004" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "types-pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" }, +] + [[package]] name = "types-regex" version = "2026.1.15.20260116" @@ -9029,6 +9144,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" }, ] +[[package]] +name = "types-setuptools" +version = "82.0.0.20260408" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/12/3464b410c50420dd4674fa5fe9d3880711c1dbe1a06f5fe4960ee9067b9e/types_setuptools-82.0.0.20260408.tar.gz", hash = "sha256:036c68caf7e672a699f5ebbf914708d40644c14e05298bc49f7272be91cf43d3", size = 44861, upload-time = "2026-04-08T04:29:33.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/e1/46a4fc3ef03aabf5d18bac9df5cf37c6b02c3bddf3e05c3533f4b4588331/types_setuptools-82.0.0.20260408-py3-none-any.whl", hash = "sha256:ece0a215cdfa6463a65fd6f68bd940f39e455729300ddfe61cab1147ed1d2462", size = 68428, upload-time = "2026-04-08T04:29:32.175Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"