JSON first crews (#6131)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled

* feat(cli): introduce JSON crew project support and TUI enhancements

- Added support for creating and running JSON-defined crew projects, allowing users to scaffold projects with a new `create_json_crew.py` file.
- Implemented a full-screen Textual TUI for crew execution in `crew_run_tui.py`, enhancing user interaction with a two-column layout.
- Updated `run_crew.py` to prioritize JSON crew projects and added daemon mode for running without TUI.
- Introduced interactive pickers in `tui_picker.py` for improved CLI prompts.
- Enhanced validation for JSON crew files in `validate.py` to ensure proper structure and agent definitions.
- Updated `.gitignore` to exclude demo and crewai directories.

* feat: update LLM model references to gpt-5.4-mini

- Changed default LLM model from gpt-4o-mini to gpt-5.4-mini across various files, including CLI options, JSON crew configurations, and agent definitions.
- Enhanced benchmark and human feedback functionalities to utilize the new model.
- Improved user interface elements in the TUI for better interaction and feedback during execution.
- Added support for new skills directory in JSON crew project creation.

* feat(benchmark): add crew-level benchmarking functionality

- Introduced a new `benchmark` command in the CLI for crew-level benchmarking, allowing users to specify agents, models, and timeout settings.
- Implemented `CrewBenchmarkCase` to handle crew-level benchmark cases with inputs and criteria.
- Enhanced the benchmark runner to support progress tracking and detailed reporting of results for multiple models.
- Added tests for loading crew benchmark cases and validating their structure.
- Updated existing benchmark functions to accommodate the new crew-level execution model.

* feat(cli): enhance JSON crew project functionality and TUI improvements

- Added optional agent-level guardrails and advanced options in JSON crew configurations to improve output validation and flexibility.
- Updated the TUI to better handle plan step statuses, including visual indicators for task completion and failure.
- Introduced methods for parsing and managing step observation events, ensuring accurate updates to task statuses during execution.
- Enhanced validation for JSON crew projects, ensuring proper structure and error handling for agent and task definitions.
- Added comprehensive tests for new features and validation logic, ensuring robustness in JSON crew project handling.

* refactor(cli): streamline JSON crew project handling and improve validation

- Refactored JSON crew project loading and validation logic to enhance clarity and maintainability.
- Introduced utility functions for finding JSON crew files, improving code reuse across modules.
- Removed deprecated benchmark functionality and associated tests to simplify the codebase.
- Updated CLI commands to utilize the new JSON project structure, ensuring compatibility with recent changes.
- Enhanced test coverage for JSON crew project features, ensuring robust validation and error handling.

* feat(cli): enhance activity log navigation and focus management

- Added functionality to focus on the activity log when navigating through log entries.
- Implemented refresh logic for the log panel to ensure updates are displayed correctly during navigation.
- Improved keyboard navigation for log entries, allowing users to expand and scroll through logs seamlessly.
- Added tests to verify the correct behavior of log navigation and focus management in the TUI.

* feat(cli): enhance JSON crew project interaction and input handling

- Introduced a new function to enable prompt line editing for better user experience during input prompts.
- Updated the JSON crew project wizards to show interpolation hints for dynamic values, improving user guidance.
- Enhanced the handling of missing input placeholders by prompting users for required values during crew setup.
- Refactored the crew run logic to ensure proper loading and preparation of JSON-defined crews, including runtime input management.
- Added tests to verify the correct behavior of new input handling features and JSON crew project interactions.

* feat(cli): improve crew project input prompts and event handling

- Enhanced the `_prompt_text` function to allow for configurable spacing before prompts, improving user experience during input collection.
- Updated the wizards for agent and task creation to utilize the new prompt configuration, ensuring a more compact and streamlined interaction.
- Introduced new plan step lifecycle events (`PlanStepStartedEvent`, `PlanStepCompletedEvent`) to better track the execution status of plan steps.
- Refactored the step executor to emit these events during the execution of tasks, improving observability and debugging capabilities.
- Added tests to verify the correct behavior of new prompt handling and event emissions during crew project execution.

* fix: refine json-first crew interactions

* fix: prioritize common json crew tools

* fix: make json crew more tools expandable

* fix: show json crew tools by category

* feat(memory): update default embedder to OpenAI text-embedding-3-large and enhance memory compatibility

- Changed the default embedding model for Memory to OpenAI text-embedding-3-large, which uses 3072-dimensional vectors.
- Added warnings regarding compatibility issues with existing local memory stores created with 1536-dimensional embeddings.
- Updated documentation to reflect the new default embedder and its configuration options.
- Enhanced the CLI and codebase to support the new embedding model across various components, ensuring a seamless transition for users.

* fix: address PR review feedback for JSON-first crews

Review blockers:
- Forward trained_agents_file to JSON crews: crewai run -f now exports
  CREWAI_TRAINED_AGENTS_FILE for the in-process JSON crew path
- Wizard agent picker: Esc/cancel now reprompts instead of silently
  assigning the first agent
- JSON tool resolution hard-fails: unknown tool names, missing custom
  tool files, and invalid custom tool modules raise JSONProjectError
  with actionable messages instead of warn-and-continue
- Embedding dimension mismatch: LanceDB and Qdrant Edge storages raise
  EmbeddingDimensionMismatchError with reset/pin guidance instead of
  silently zero-filling vectors or returning empty search results
- Custom tool code execution documented in loader docstring and the
  scaffolded project README

CI fixes:
- ruff format across lib/
- All 133 PR-introduced mypy errors fixed (llm.py lazy-litellm and
  cli.py lazy command shims now use TYPE_CHECKING imports; textual
  is_mounted misuse fixed; pick_many overloads; misc annotations)

Bot review comments:
- Empty except blocks now have explanatory comments or debug logging
- Removed unused _C_BG/_C_PANEL/_C_BORDER globals and redundant
  import re; tests use a single import style for create_json_crew

Tests: trained-agents propagation, wizard cancel, tool resolution
failures, and dimension mismatch guidance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address second round of PR review comments

Cursor Bugbot:
- Wizard agent slugs: strip to [a-z0-9_] and fall back to agent_<n> so
  symbol-only roles can't produce an empty agents/.jsonc filename
- Wizard task names: dedupe against prior task names and fall back to
  task_<n> for symbol-only descriptions

CodeRabbit:
- Agent.message(): import Task explicitly at runtime instead of relying
  on the namespace injection done by crewai/__init__
- Async executor: move the native-tools-unsupported fallback from
  _ainvoke_loop_react (self-recursion) to _ainvoke_loop_native_tools,
  mirroring the sync implementation
- StepExecutor downgrade: keep the in-step conversation and append the
  text-tooling instructions instead of rebuilding messages, so completed
  native tool calls are not re-executed
- crewai-files: extension-based MIME lookup now runs before byte
  sniffing so csv/xml types are not degraded to text/plain
- Memory storages: validate every record in a save() batch against a
  consistent embedding dimension (LanceDB previously checked only the
  first record); added mixed-batch tests
- _print_post_tui_summary now typed against CrewRunApp
- Docs: Azure OpenAI default embedder change called out in the memory
  migration warning and provider table

Code quality bots:
- Removed unused _C_YELLOW/_C_CYAN (crew_run_tui) and _GREEN (tui_picker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): accordion tool picker in JSON crew wizard

The flat tool list had grown to ~90 rows. The picker now shows:
- Common tools always visible at the top
- Every other category as a single expandable row with tool and
  selection counts (e.g. "Search & Research  (27 tools, 2 selected)")
- Expanding a category collapses the previously expanded one
- Selections persist across expand/collapse via new preselected
  support in pick_many; cursor follows the toggled category row

tui_picker gains preselected + initial_cursor options on pick_many,
and Esc in multi-select now confirms the current selection instead of
discarding it (required so collapsing can't silently drop choices).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(cli): remove --daemon flag from crewai run

The flag only affected JSON crew projects — classic and flow projects
ignored it entirely, which made the behavior inconsistent. Removed the
option, the daemon code path (_run_json_crew_daemon), and its helper
(_load_json_crew_with_inputs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update run command tests after --daemon removal

lib/crewai/tests/cli/test_run_crew.py still asserted the old
run_crew(trained_agents_file=..., daemon=False) call signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): exit codes, mid-run quit, async statuses, hyphen placeholders

Addresses the latest Bugbot review round:

- Failed JSON crew runs now exit non-zero (SystemExit(1)) so scripts
  and CI don't treat failures as success, mirroring the classic path
- Quitting the TUI mid-run now ends the process (os._exit(130));
  kickoff runs in a thread worker that cannot be force-cancelled, so
  letting the CLI return would leave LLM/tool work burning tokens in
  the background
- Sidebar task statuses are now async-safe: completion/failure events
  resolve the task's own row via identity instead of assuming the most
  recently started task, and starting a task no longer blanket-marks
  earlier active rows as done
- The runtime-input prompt regex now accepts hyphenated placeholder
  names ({my-topic}), matching kickoff's interpolation pattern

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: validation safety, custom tool sandboxing, TUI log integrity, memory error surfacing

- Deploy validation no longer executes project code: validation mode
  checks tool declarations structurally (well-formed entries, custom
  tool file exists) without importing or instantiating anything.
  custom:<name> resolution only happens on the actual run path.
- custom:<name> is constrained to [A-Za-z_][A-Za-z0-9_]* and the
  resolved path must stay inside the project's tools/ directory, so
  custom:../foo or absolute-path names cannot execute code outside it.
  Tool paths resolve relative to the crew project root, not cwd.
- TUI task logs are built from per-task state captured at task start
  (idx, description, agent, start time); an out-of-order completion
  takes its output from the event and no longer steals or resets the
  current task's streamed steps/output.
- EmbeddingDimensionMismatchError now inherits ValueError instead of
  RuntimeError so background saves surface it through
  MemorySaveFailedEvent instead of silently dropping the save; the
  shutdown catch in _background_encode_batch is narrowed to the
  "cannot schedule new futures" case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): declared project type wins over crew.json presence

A flow project that also contains a crew.json(c) file now runs and
validates as the flow it declares in pyproject.toml instead of being
hijacked by the JSON crew path. Both crewai run (_has_json_crew) and
deploy validation (_is_json_crew) check tool.crewai.type; a missing or
unreadable pyproject still means a bare JSON crew project.

Also documents why StepObservationFailedEvent intentionally marks the
plan step "done": the event signals an observer failure, not a step
failure, and the executor continues past it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): type the declared_type locals so mypy stays clean

Comparing an Any-typed .get() chain returns Any, which tripped
no-any-return on the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
João Moura
2026-06-14 04:19:48 -03:00
committed by GitHub
parent d80719df81
commit bb477f8a91
81 changed files with 9088 additions and 235 deletions

View File

@@ -3,42 +3,94 @@ from __future__ import annotations
from importlib.metadata import version as get_version
import os
import subprocess
from typing import Any
from typing import TYPE_CHECKING, 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.experimental.skills.main import SkillCommand
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.run_flow_definition import run_flow_definition
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
from crewai_cli.utils import (
build_env_with_all_tool_credentials,
enable_prompt_line_editing,
read_toml,
)
def train_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.train_crew import train_crew as _train_crew
return _train_crew(*args, **kwargs)
def evaluate_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.evaluate_crew import evaluate_crew as _evaluate_crew
return _evaluate_crew(*args, **kwargs)
def replay_task_command(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.replay_from_task import replay_task_command as _replay_task_command
return _replay_task_command(*args, **kwargs)
def run_flow_definition(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_flow_definition import (
run_flow_definition as _run_flow_definition,
)
return _run_flow_definition(*args, **kwargs)
def run_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_crew import run_crew as _run_crew
return _run_crew(*args, **kwargs)
if TYPE_CHECKING:
# mypy sees the real classes; at runtime the shims below defer the
# heavy imports until a command actually instantiates them.
from crewai_cli.authentication.main import AuthenticationCommand
from crewai_cli.deploy.main import DeployCommand
from crewai_cli.organization.main import OrganizationCommand
from crewai_cli.remote_template.main import TemplateCommand
else:
class AuthenticationCommand:
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
from crewai_cli.authentication.main import (
AuthenticationCommand as _AuthenticationCommand,
)
return _AuthenticationCommand(*args, **kwargs)
class DeployCommand:
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
from crewai_cli.deploy.main import DeployCommand as _DeployCommand
return _DeployCommand(*args, **kwargs)
class TemplateCommand:
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
from crewai_cli.remote_template.main import (
TemplateCommand as _TemplateCommand,
)
return _TemplateCommand(*args, **kwargs)
class OrganizationCommand:
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
from crewai_cli.organization.main import (
OrganizationCommand as _OrganizationCommand,
)
return _OrganizationCommand(*args, **kwargs)
def _get_cli_version() -> str:
@@ -91,17 +143,57 @@ def uv(uv_args: tuple[str, ...]) -> None:
@crewai.command()
@click.argument("type", type=click.Choice(["crew", "flow"]))
@click.argument("name")
@click.argument(
"type", required=False, default=None, type=click.Choice(["crew", "flow"])
)
@click.argument("name", required=False, default=None)
@click.option("--provider", type=str, help="The provider to use for the crew")
@click.option("--skip_provider", is_flag=True, help="Skip provider validation")
@click.option(
"--classic",
is_flag=True,
help="Use classic Python/YAML project structure instead of JSON",
)
def create(
type: str, name: str, provider: str | None, skip_provider: bool = False
type: str | None,
name: str | None,
provider: str | None,
skip_provider: bool = False,
classic: bool = False,
) -> None:
"""Create a new crew, or flow."""
if not type:
from crewai_cli.tui_picker import pick
options = [
("crew", "A team of AI agents working together"),
(
"flow",
"A deterministic workflow with full control over agents and crews",
),
]
type = pick("What would you like to create?", options)
if type is None:
raise SystemExit(0)
click.echo()
if not name:
enable_prompt_line_editing()
name = click.prompt(
click.style(f" Name of your {type}", fg="cyan", bold=True),
prompt_suffix=click.style(" ", fg="bright_white"), # noqa: RUF001
)
if type == "crew":
create_crew(name, provider, skip_provider)
if classic:
from crewai_cli.create_crew import create_crew
create_crew(name, provider, skip_provider)
else:
from crewai_cli.create_json_crew import create_json_crew
create_json_crew(name, provider, skip_provider)
elif type == "flow":
from crewai_cli.create_flow import create_flow
create_flow(name)
else:
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
@@ -186,6 +278,8 @@ def replay(task_id: str, trained_agents_file: str | None) -> None:
def log_tasks_outputs() -> None:
"""Retrieve your latest crew.kickoff() task outputs."""
try:
from crewai_cli.task_outputs import load_task_outputs
tasks = load_task_outputs()
if not tasks:
@@ -274,6 +368,8 @@ def reset_memories(
"Please specify at least one memory type to reset using the appropriate flags."
)
return
from crewai_cli.reset_memories_command import reset_memories_command
reset_memories_command(memory, knowledge, agent_knowledge, kickoff_outputs, all)
except Exception as e:
click.echo(f"An error occurred while resetting memories: {e}", err=True)
@@ -296,7 +392,7 @@ def reset_memories(
"--embedder-model",
type=str,
default=None,
help="Embedder model name (e.g. text-embedding-3-small, gemini-embedding-001).",
help="Embedder model name (e.g. text-embedding-3-large, gemini-embedding-001).",
)
@click.option(
"--embedder-config",
@@ -351,7 +447,7 @@ def memory(
"-m",
"--model",
type=str,
default="gpt-4o-mini",
default="gpt-5.4-mini",
help="LLM Model to run the tests on the Crew. For now only accepting only OpenAI models.",
)
@click.option(
@@ -382,6 +478,8 @@ def test(n_iterations: int, model: str, trained_agents_file: str | None) -> None
@click.pass_context
def install(context: click.Context) -> None:
"""Install the Crew."""
from crewai_cli.install_crew import install_crew
install_crew(context.args)
@@ -415,7 +513,9 @@ def install(context: click.Context) -> None:
help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
)
def run(
trained_agents_file: str | None, definition: str | None, inputs: str | None
trained_agents_file: str | None,
definition: str | None,
inputs: str | None,
) -> None:
"""Run the Crew or Flow."""
if inputs is not None and definition is None:
@@ -435,6 +535,8 @@ def run(
@crewai.command()
def update() -> None:
"""Update the pyproject.toml of the Crew project to use uv."""
from crewai_cli.update_crew import update_crew
update_crew()
@@ -544,6 +646,8 @@ def tool() -> None:
@tool.command(name="create")
@click.argument("handle")
def tool_create(handle: str) -> None:
from crewai_cli.tools.main import ToolCommand
tool_cmd = ToolCommand()
tool_cmd.create(handle)
@@ -551,6 +655,8 @@ def tool_create(handle: str) -> None:
@tool.command(name="install")
@click.argument("handle")
def tool_install(handle: str) -> None:
from crewai_cli.tools.main import ToolCommand
tool_cmd = ToolCommand()
tool_cmd.login()
tool_cmd.install(handle)
@@ -567,6 +673,8 @@ def tool_install(handle: str) -> None:
@click.option("--public", "is_public", flag_value=True, default=False)
@click.option("--private", "is_public", flag_value=False)
def tool_publish(is_public: bool, force: bool) -> None:
from crewai_cli.tools.main import ToolCommand
tool_cmd = ToolCommand()
tool_cmd.login()
tool_cmd.publish(is_public, force)
@@ -599,6 +707,8 @@ def skill() -> None:
help="Create skill in current dir instead of ./skills/",
)
def skill_create(name: str, in_project: bool) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.create(name, in_project=in_project)
@@ -606,6 +716,8 @@ def skill_create(name: str, in_project: bool) -> None:
@skill.command(name="install")
@click.argument("ref")
def skill_install(ref: str) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.install(ref)
@@ -622,6 +734,8 @@ def skill_install(ref: str) -> None:
@click.option("--private", "is_public", flag_value=False)
@click.option("--org", default=None, help="Organisation slug (overrides settings).")
def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.publish(is_public, org=org, force=force)
@@ -629,6 +743,8 @@ def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
@skill.command(name="list")
def skill_list() -> None:
"""List locally installed skills."""
from crewai_cli.experimental.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.list_cached()
@@ -668,6 +784,8 @@ def flow() -> None:
@flow.command(name="kickoff")
def flow_run() -> None:
"""Kickoff the Flow."""
from crewai_cli.kickoff_flow import kickoff_flow
click.echo("Running the Flow")
kickoff_flow()
@@ -675,6 +793,8 @@ def flow_run() -> None:
@flow.command(name="plot")
def flow_plot() -> None:
"""Plot the Flow."""
from crewai_cli.plot_flow import plot_flow
click.echo("Plotting the Flow")
plot_flow()
@@ -683,6 +803,8 @@ def flow_plot() -> None:
@click.argument("crew_name")
def flow_add_crew(crew_name: str) -> None:
"""Add a crew to an existing flow."""
from crewai_cli.add_crew_to_flow import add_crew_to_flow
click.echo(f"Adding crew {crew_name} to the flow")
add_crew_to_flow(crew_name)
@@ -695,6 +817,8 @@ def triggers() -> None:
@triggers.command(name="list")
def triggers_list() -> None:
"""List all available triggers from integrations."""
from crewai_cli.triggers.main import TriggersCommand
triggers_cmd = TriggersCommand()
triggers_cmd.list_triggers()
@@ -703,6 +827,8 @@ def triggers_list() -> None:
@click.argument("trigger_path")
def triggers_run(trigger_path: str) -> None:
"""Execute crew with trigger payload. Format: app_slug/trigger_slug"""
from crewai_cli.triggers.main import TriggersCommand
triggers_cmd = TriggersCommand()
triggers_cmd.execute_with_trigger(trigger_path)
@@ -715,6 +841,8 @@ def chat() -> None:
click.secho(
"\nStarting a conversation with the Crew\nType 'exit' or Ctrl+C to quit.\n",
)
from crewai_cli.crew_chat import run_chat
run_chat()
@@ -754,6 +882,8 @@ def enterprise() -> None:
@click.argument("enterprise_url")
def enterprise_configure(enterprise_url: str) -> None:
"""Configure CrewAI AMP OAuth2 settings from the provided Enterprise URL."""
from crewai_cli.enterprise.main import EnterpriseConfigureCommand
enterprise_command = EnterpriseConfigureCommand()
enterprise_command.configure(enterprise_url)
@@ -766,6 +896,8 @@ def config() -> None:
@config.command("list")
def config_list() -> None:
"""List all CLI configuration parameters."""
from crewai_cli.settings.main import SettingsCommand
config_command = SettingsCommand()
config_command.list()
@@ -775,6 +907,8 @@ def config_list() -> None:
@click.argument("value")
def config_set(key: str, value: str) -> None:
"""Set a CLI configuration parameter."""
from crewai_cli.settings.main import SettingsCommand
config_command = SettingsCommand()
config_command.set(key, value)
@@ -782,6 +916,8 @@ def config_set(key: str, value: str) -> None:
@config.command("reset")
def config_reset() -> None:
"""Reset all CLI configuration parameters to default values."""
from crewai_cli.settings.main import SettingsCommand
config_command = SettingsCommand()
config_command.reset_all_settings()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,39 @@ def _run_predeploy_validation(skip_validate: bool) -> bool:
return True
def _display_git_repository_help() -> None:
"""Explain how to prepare a new project for deployment."""
console.print(
"Deployment requires a Git repository with an origin remote.",
style="bold red",
)
console.print(
"CrewAI AMP deploys from the remote repository URL, so commit and push "
"this project first, then run deploy again.",
style="yellow",
)
console.print("\nSuggested setup:")
console.print(" git init")
console.print(" git add .")
console.print(' git commit -m "Initial crew"')
console.print(" git branch -M main")
console.print(" git remote add origin <your-repo-url>")
console.print(" git push -u origin main")
def _display_git_remote_help() -> None:
"""Explain how to add a remote to an existing Git repository."""
console.print("No remote repository URL found.", style="bold red")
console.print(
"CrewAI AMP deploys from the origin remote. Add a remote, push your "
"latest commit, then run deploy again.",
style="yellow",
)
console.print("\nSuggested setup:")
console.print(" git remote add origin <your-repo-url>")
console.print(" git push -u origin HEAD")
class DeployCommand(BaseCommand, PlusAPIMixin):
"""
A class to handle deployment-related operations for CrewAI projects.
@@ -124,14 +157,11 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
try:
remote_repo_url = git.Repository().origin_url()
except ValueError:
remote_repo_url = None
_display_git_repository_help()
return
if remote_repo_url is None:
console.print("No remote repository URL found.", style="bold red")
console.print(
"Please ensure your project has a valid remote repository.",
style="yellow",
)
_display_git_remote_help()
return
self._confirm_input(env_vars, remote_repo_url, confirm)

View File

@@ -38,6 +38,12 @@ import subprocess
import sys
from typing import Any
from crewai.project.json_loader import (
JSONProjectValidationError,
find_crew_json_file,
find_json_project_file,
validate_crew_project,
)
from rich.console import Console
from crewai_cli.utils import parse_toml
@@ -151,9 +157,33 @@ class DeployValidator:
def ok(self) -> bool:
return not self.errors
@property
def _is_json_crew(self) -> bool:
"""True for JSON crew projects, deferring to the declared type.
A flow project that also contains a crew.json(c) file validates as
the flow it declares in pyproject.toml, not as a JSON crew.
"""
if find_crew_json_file(self.project_root) is None:
return False
pyproject_path = self.project_root / "pyproject.toml"
if not pyproject_path.exists():
return True
try:
data = parse_toml(pyproject_path.read_text())
except Exception:
return True
declared_type: str | None = (
(data.get("tool") or {}).get("crewai", {}).get("type")
)
return declared_type != "flow"
def run(self) -> list[ValidationResult]:
"""Run all checks. Later checks are skipped when earlier ones make
them impossible (e.g. no pyproject.toml → no lockfile check)."""
if self._is_json_crew:
return self._run_json_checks()
if not self._check_pyproject():
return self.results
@@ -176,6 +206,110 @@ class DeployValidator:
return self.results
def _run_json_checks(self) -> list[ValidationResult]:
"""Validation suite for JSON-defined crew projects."""
crew_path = find_crew_json_file(self.project_root)
if crew_path is None:
return self.results
try:
project = validate_crew_project(crew_path, self.project_root / "agents")
except JSONProjectValidationError as e:
self._add(
Severity.ERROR,
"invalid_crew_json",
f"{crew_path.name} has invalid JSON crew configuration",
detail="\n".join(e.errors),
hint="Fix the JSON crew, agent, and task references before deploying.",
)
return self.results
except Exception as e:
self._add(
Severity.ERROR,
"invalid_crew_json",
f"Cannot parse {crew_path.name}",
detail=str(e),
)
return self.results
agents_dir = self.project_root / "agents"
self._check_pyproject()
self._check_lockfile()
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
self._check_version_vs_lockfile()
return self.results
def _check_env_vars_json(
self, crew_path: Path, agents_dir: Path, agent_names: list[str]
) -> None:
"""Check for env var references in JSON crew files."""
referenced: set[str] = set()
pattern = re.compile(r"\$\{?([A-Z][A-Z0-9_]+)\}?")
try:
referenced.update(pattern.findall(crew_path.read_text(errors="ignore")))
except OSError as exc:
logger.debug("Skipping unreadable crew file %s: %s", crew_path, exc)
for name in agent_names:
agent_path = find_json_project_file(agents_dir, name)
if agent_path is None:
continue
try:
referenced.update(
pattern.findall(agent_path.read_text(errors="ignore"))
)
except OSError as exc:
logger.debug("Skipping unreadable agent file %s: %s", agent_path, exc)
for py_path in self.project_root.rglob("*.py"):
if ".venv" in py_path.parts:
continue
try:
text = py_path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
env_pattern = re.compile(
r"""(?x)
(?:os\.environ\s*(?:\[\s*|\.get\s*\(\s*)
|os\.getenv\s*\(\s*
|getenv\s*\(\s*)
['"]([A-Z][A-Z0-9_]*)['"]
"""
)
referenced.update(env_pattern.findall(text))
env_file = self.project_root / ".env"
env_keys: set[str] = set()
if env_file.exists():
for line in env_file.read_text(errors="ignore").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
env_keys.add(line.split("=", 1)[0].strip())
missing_known = sorted(
var
for var in referenced
if var in _KNOWN_API_KEY_HINTS
and var not in env_keys
and var not in os.environ
)
if missing_known:
self._add(
Severity.WARNING,
"env_vars_not_in_dotenv",
f"{len(missing_known)} referenced API key(s) not in .env",
detail=(
"These env vars are referenced in your project but not set "
f"locally: {', '.join(missing_known)}. Deploys will fail "
"unless they are added to the deployment's Environment "
"Variables in the CrewAI dashboard."
),
)
def _check_pyproject(self) -> bool:
pyproject_path = self.project_root / "pyproject.toml"
if not pyproject_path.exists():

View File

@@ -48,6 +48,7 @@ class Repository:
["git", "rev-parse", "--is-inside-work-tree"], # noqa: S607
cwd=self.path,
encoding="utf-8",
stderr=subprocess.DEVNULL,
)
return True
except subprocess.CalledProcessError:

View File

@@ -1,25 +1,311 @@
from __future__ import annotations
from contextlib import AbstractContextManager, nullcontext
from enum import Enum
import os
from pathlib import Path
import re
import subprocess
import sys
from typing import TYPE_CHECKING, Any
import click
from crewai.project.json_loader import find_crew_json_file
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_cli.utils import (
build_env_with_all_tool_credentials,
enable_prompt_line_editing,
read_toml,
)
from crewai_cli.version import get_crewai_version
if TYPE_CHECKING:
from crewai_cli.crew_run_tui import CrewRunApp
class CrewType(Enum):
STANDARD = "standard"
FLOW = "flow"
def run_crew(trained_agents_file: str | None = None) -> None:
"""Run the crew or flow by running a command in the UV environment.
# Must accept the same names as the kickoff interpolation pattern in
# crewai.utilities.string_utils (_VARIABLE_PATTERN), including hyphens —
# otherwise placeholders are interpolated at runtime but never prompted for.
_INPUT_PLACEHOLDER_RE = re.compile(r"(?<!{){([A-Za-z_][A-Za-z0-9_\-]*)}(?!})")
Starting from version 0.103.0, this command can be used to run both
standard crews and flows. For flows, it detects the type from pyproject.toml
and automatically runs the appropriate command.
def _has_json_crew() -> bool:
"""Check if this is a JSON-defined crew project.
The project type declared in pyproject.toml wins: a flow project that
happens to contain a crew.json(c) file still runs as a flow. A missing
or unreadable pyproject means a bare JSON crew project.
"""
if find_crew_json_file() is None:
return False
try:
pyproject_data = read_toml()
except Exception:
return True
declared_type: str | None = (
pyproject_data.get("tool", {}).get("crewai", {}).get("type")
)
return declared_type != "flow"
def _extract_input_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return set(_INPUT_PLACEHOLDER_RE.findall(text))
def _missing_input_names(crew: Any, inputs: dict[str, Any]) -> list[str]:
"""Return input placeholders used by a crew but not provided as defaults."""
placeholders: set[str] = set()
for agent in getattr(crew, "agents", []) or []:
placeholders.update(_extract_input_placeholders(getattr(agent, "role", None)))
placeholders.update(_extract_input_placeholders(getattr(agent, "goal", None)))
placeholders.update(
_extract_input_placeholders(getattr(agent, "backstory", None))
)
for task in getattr(crew, "tasks", []) or []:
placeholders.update(
_extract_input_placeholders(getattr(task, "description", None))
)
placeholders.update(
_extract_input_placeholders(getattr(task, "expected_output", None))
)
placeholders.update(
_extract_input_placeholders(getattr(task, "output_file", None))
)
return sorted(name for name in placeholders if name not in inputs)
def _prompt_for_missing_inputs(
crew: Any, default_inputs: dict[str, Any]
) -> dict[str, Any]:
"""Ask for runtime values for placeholders that lack default inputs."""
inputs = dict(default_inputs or {})
missing = _missing_input_names(crew, inputs)
if not missing:
return inputs
enable_prompt_line_editing()
click.echo()
click.secho(" Runtime inputs", fg="cyan", bold=True)
click.secho(
" Values for {placeholder} references in your agents and tasks.",
dim=True,
)
for name in missing:
inputs[name] = click.prompt(
click.style(f" {name}", fg="cyan"),
prompt_suffix=click.style(" > ", fg="bright_white"),
)
return inputs
def _json_loading_status(message: str) -> AbstractContextManager[Any]:
from rich.console import Console
from rich.text import Text
console = Console()
if not console.is_terminal:
return nullcontext()
return console.status(
Text(f" {message}", style="bold #1F7982"),
spinner="dots",
)
def _load_json_crew(crew_path: Path) -> tuple[Any, dict[str, Any]]:
from crewai.project.crew_loader import load_crew
return load_crew(crew_path)
def _load_json_crew_for_tui(
crew_path: Path,
) -> tuple[type[Any], Any, dict[str, Any], list[str], list[str]]:
with _json_loading_status("Preparing crew..."):
from crewai_cli.crew_run_tui import CrewRunApp
crew, default_inputs = _load_json_crew(crew_path)
_prepare_json_crew_for_tui(crew)
task_names = [
getattr(task, "name", "") or getattr(task, "description", "")[:40] or "Task"
for task in crew.tasks
]
agent_names = [
getattr(agent, "role", "") or getattr(agent, "name", "") or "Agent"
for agent in crew.agents
]
return CrewRunApp, crew, default_inputs, task_names, agent_names
def _prepare_json_crew_for_tui(crew: Any) -> None:
"""Apply the same quiet/streaming setup used by the TUI JSON loader."""
crew.verbose = False
for agent in crew.agents:
agent.verbose = False
if hasattr(agent, "llm") and hasattr(agent.llm, "stream"):
agent.llm.stream = True
def _run_json_crew(trained_agents_file: str | None = None) -> Any:
"""Load and run a JSON-defined crew."""
from dotenv import load_dotenv
env_file = Path.cwd() / ".env"
if env_file.exists():
load_dotenv(env_file, override=True)
# JSON crews run in-process, so export the trained-agents file directly
# instead of forwarding it to a subprocess like classic crews do.
if trained_agents_file:
os.environ[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
crew_path = find_crew_json_file()
if crew_path is None:
raise FileNotFoundError("No crew.jsonc or crew.json found")
crew_run_app_cls, crew, default_inputs, task_names, agent_names = (
_load_json_crew_for_tui(crew_path)
)
runtime_inputs = _prompt_for_missing_inputs(crew, default_inputs)
app = crew_run_app_cls(
crew_name=crew.name or "Crew",
total_tasks=len(crew.tasks),
agent_names=agent_names,
task_names=task_names,
)
app._crew = crew
app._default_inputs = runtime_inputs
app.run()
_print_post_tui_summary(app)
if app._status == "failed":
# Mirror the classic subprocess path: a failed crew must produce a
# non-zero exit code so scripts and CI don't treat it as success.
raise SystemExit(1)
if app._status not in ("completed", "failed"):
# User quit mid-run. kickoff runs in a thread worker that cannot be
# force-cancelled, so end the process to stop in-flight LLM and tool
# work instead of letting it burn tokens in the background.
click.secho("\n Run cancelled.", fg="yellow")
sys.stdout.flush()
os._exit(130)
if getattr(app, "_want_deploy", False):
_chain_deploy()
return app._crew_result
def _chain_deploy() -> None:
from rich.console import Console
console = Console()
try:
from crewai_cli.deploy.main import DeployCommand
console.print("\nStarting deployment…\n", style="bold #FF5A50")
DeployCommand().create_crew(confirm=False, skip_validate=True)
except SystemExit:
from crewai_cli.authentication.main import AuthenticationCommand
console.print()
AuthenticationCommand().login()
try:
DeployCommand().create_crew(confirm=False, skip_validate=True)
except Exception as e:
console.print(f"\nDeploy failed: {e}\n", style="bold red")
except Exception as e:
console.print(f"\nDeploy failed: {e}\n", style="bold red")
def _print_post_tui_summary(app: CrewRunApp) -> None:
"""Print a summary to the terminal after the Textual TUI exits."""
import time
from rich.console import Console
from rich.markdown import Markdown
from rich.padding import Padding
from rich.panel import Panel
from rich.text import Text
console = Console()
elapsed = time.time() - app._start_time
out_tokens = app._output_tokens + app._live_out_tokens
token_parts = []
if app._input_tokens:
token_parts.append(f"{app._input_tokens:,}")
if out_tokens:
token_parts.append(f"{out_tokens:,}")
token_str = " ".join(token_parts)
if token_str:
token_str += " tokens"
crewai_red = "#FF5A50"
crewai_teal = "#1F7982"
if app._status == "completed":
summary = Text()
summary.append(
f" ✔ Completed {app._total_tasks} tasks",
style=f"bold {crewai_teal}",
)
summary.append(f" in {elapsed:.1f}s", style="dim")
if token_str:
summary.append(f" {token_str}", style="dim")
console.print(
Panel(
summary,
title=f" {app._crew_name} ",
title_align="left",
border_style=crewai_teal,
padding=(0, 1),
)
)
if app._final_output:
console.print()
console.print(Text(" Final Result", style=f"bold {crewai_teal}"))
console.print()
console.print(Padding(Markdown(app._final_output), (0, 2)))
elif app._status == "failed":
content = Text()
content.append(" ✘ Failed", style=f"bold {crewai_red}")
content.append(f" after {elapsed:.1f}s\n", style="dim")
if app._error:
content.append(f"\n {app._error}\n", style=crewai_red)
console.print(
Panel(
content,
title=f" {app._crew_name} ",
title_align="left",
border_style=crewai_red,
padding=(0, 1),
)
)
def run_crew(trained_agents_file: str | None = None) -> None:
"""Run the crew or flow.
Args:
trained_agents_file: Optional path to a trained-agents pickle produced
@@ -27,6 +313,11 @@ def run_crew(trained_agents_file: str | None = None) -> None:
``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this
file instead of the default ``trained_agents_data.pkl``.
"""
# JSON crew projects take precedence
if _has_json_crew():
_run_json_crew(trained_agents_file=trained_agents_file)
return
crewai_version = get_crewai_version()
min_required_version = "0.71.0"
pyproject_data = read_toml()

View File

@@ -0,0 +1,419 @@
"""Arrow-key interactive pickers for CLI prompts."""
from __future__ import annotations
from contextlib import suppress
import sys
from typing import overload
import click
# CrewAI brand: primary=#FF5A50 (coral), teal=#1F7982
_CORAL = "\033[38;2;255;90;80m" # #FF5A50
_TEAL = "\033[38;2;31;121;130m" # #1F7982
_BOLD = "\033[1m"
_DIM = "\033[2m"
_RESET = "\033[0m"
_HIDE_CURSOR = "\033[?25l"
_SHOW_CURSOR = "\033[?25h"
def _is_interactive() -> bool:
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except Exception:
return False
def _read_key() -> str:
if sys.platform == "win32":
import msvcrt
ch = msvcrt.getwch()
if ch in ("\x00", "\xe0"):
ch2 = msvcrt.getwch()
return {"H": "up", "P": "down"}.get(ch2, "")
if ch == "\r":
return "enter"
if ch == " ":
return "space"
if ch == "\x03":
raise KeyboardInterrupt
return ch
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
seq = sys.stdin.read(2)
if seq == "[A":
return "up"
if seq == "[B":
return "down"
return "esc"
if ch in ("\r", "\n"):
return "enter"
if ch == " ":
return "space"
if ch == "\x03":
raise KeyboardInterrupt
return ch
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _clear_lines(n: int) -> None:
sys.stdout.write(f"\033[{n}A")
for _ in range(n):
sys.stdout.write("\033[2K\n")
sys.stdout.write(f"\033[{n}A")
sys.stdout.flush()
def _draw_single(labels: list[str], cursor: int, *, clear: bool = False) -> None:
total = len(labels)
if clear:
sys.stdout.write(f"\033[{total}A")
for i, label in enumerate(labels):
if i == cursor:
sys.stdout.write(f"\033[2K {_CORAL}{_RESET} {_BOLD}{label}{_RESET}\n")
else:
sys.stdout.write(f"\033[2K {label}\n")
sys.stdout.flush()
def _draw_multi(
labels: list[str],
cursor: int,
selected: set[int],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
clear: bool = False,
) -> None:
action_indices = action_indices or set()
separator_indices = separator_indices or set()
hint_text = "↑↓ navigate, space toggle, enter confirm"
if action_indices:
hint_text = "↑↓ navigate, space toggle, enter confirm, ▸ rows expand/collapse"
hint = f" {_DIM}{hint_text}{_RESET}"
total = len(labels) + 1
if clear:
sys.stdout.write(f"\033[{total}A")
sys.stdout.write(f"\033[2K{hint}\n")
for i, label in enumerate(labels):
if i in separator_indices:
sys.stdout.write(f"\033[2K {_TEAL}{label}{_RESET}\n")
continue
if i in action_indices:
check = " "
elif i in selected:
check = f"{_CORAL}[x]{_RESET}"
else:
check = "[ ]"
arrow = f"{_CORAL}{_RESET} " if i == cursor else " "
bold = f"{_BOLD}{label}{_RESET}" if i == cursor else label
sys.stdout.write(f"\033[2K {arrow}{check} {bold}\n")
sys.stdout.flush()
def _arrow_select_one(labels: list[str]) -> int:
cursor = 0
total = len(labels)
sys.stdout.write(_HIDE_CURSOR)
sys.stdout.flush()
try:
_draw_single(labels, cursor)
while True:
key = _read_key()
if key == "up" and cursor > 0:
cursor -= 1
_draw_single(labels, cursor, clear=True)
elif key == "down" and cursor < total - 1:
cursor += 1
_draw_single(labels, cursor, clear=True)
elif key == "enter":
_clear_lines(total)
return cursor
elif key in ("esc", "q"):
_clear_lines(total)
return -1
finally:
sys.stdout.write(_SHOW_CURSOR)
sys.stdout.flush()
def _arrow_select_multi(
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> tuple[list[int], int | None]:
total = len(labels)
selected: set[int] = set(preselected or ())
action_indices = action_indices or set()
separator_indices = separator_indices or set()
if initial_cursor is not None and 0 <= initial_cursor < total:
cursor = initial_cursor
else:
cursor = _first_selectable_index(total, separator_indices)
sys.stdout.write(_HIDE_CURSOR)
sys.stdout.flush()
try:
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
)
while True:
key = _read_key()
if key == "up":
cursor = _next_selectable_index(cursor, -1, total, separator_indices)
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "down":
cursor = _next_selectable_index(cursor, 1, total, separator_indices)
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "space":
if cursor in action_indices:
_clear_lines(total + 1)
return sorted(selected), cursor
selected ^= {cursor}
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "enter":
_clear_lines(total + 1)
if cursor in action_indices:
return sorted(selected), cursor
return sorted(selected), None
elif key in ("esc", "q"):
_clear_lines(total + 1)
return sorted(selected), None
finally:
sys.stdout.write(_SHOW_CURSOR)
sys.stdout.flush()
def _numbered_select(labels: list[str]) -> int:
for idx, label in enumerate(labels, 1):
click.echo(f" {idx}. {label}")
click.echo()
while True:
choice = click.prompt(" Select", type=str, default="1")
if choice.lower() == "q":
return -1
try:
num = int(choice)
if 1 <= num <= len(labels):
return num - 1
except ValueError:
# Non-numeric input falls through to the shared error message.
pass
click.secho(f" Invalid choice. Enter 1-{len(labels)}.", fg="red")
def _numbered_select_multi(
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
) -> tuple[list[int], int | None]:
action_indices = action_indices or set()
separator_indices = separator_indices or set()
numbered_indices: list[int] = []
for idx, label in enumerate(labels):
if idx in separator_indices:
click.secho(f" {label}", fg="cyan")
continue
numbered_indices.append(idx)
click.echo(f" {len(numbered_indices)}. {label}")
click.echo()
raw = click.prompt(
" Select (comma-separated numbers, or empty to skip)",
default="",
show_default=False,
)
if not raw.strip():
return sorted(preselected or ()), None
indices: list[int] = list(preselected or ())
for part in raw.split(","):
with suppress(ValueError):
num = int(part.strip())
if 1 <= num <= len(numbered_indices):
idx = numbered_indices[num - 1]
if idx in action_indices:
return sorted(set(indices)), idx
indices.append(idx)
return sorted(set(indices)), None
def _first_selectable_index(total: int, separator_indices: set[int]) -> int:
for idx in range(total):
if idx not in separator_indices:
return idx
return 0
def _next_selectable_index(
cursor: int,
direction: int,
total: int,
separator_indices: set[int],
) -> int:
next_cursor = cursor + direction
while 0 <= next_cursor < total:
if next_cursor not in separator_indices:
return next_cursor
next_cursor += direction
return cursor
# ── Public API ──────────────────────────────────────────────────
def pick(title: str, options: list[tuple[str, str]]) -> str | None:
"""Arrow-key single-select picker.
Args:
title: Header text.
options: List of ``(value, description)`` tuples.
Returns:
The *value* of the selected option, or ``None`` if cancelled.
"""
labels = [f"{value:<12s} {desc}" for value, desc in options]
click.echo()
click.secho(f" {title}", fg="cyan", bold=True)
click.echo()
if _is_interactive():
try:
idx = _arrow_select_one(labels)
except Exception:
idx = _numbered_select(labels)
else:
idx = _numbered_select(labels)
if idx < 0:
return None
value, _desc = options[idx]
click.secho(f"{value}", fg="green")
return value
def pick_one(title: str, labels: list[str]) -> int:
"""Arrow-key single-select from plain labels.
Returns:
Selected index, or ``-1`` if cancelled.
"""
click.echo()
click.secho(f" {title}", fg="cyan")
if _is_interactive():
try:
return _arrow_select_one(labels)
except Exception:
return _numbered_select(labels)
return _numbered_select(labels)
@overload
def pick_many(
title: str,
labels: list[str],
*,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> list[int]: ...
@overload
def pick_many(
title: str,
labels: list[str],
*,
action_indices: set[int],
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> tuple[list[int], int | None]: ...
def pick_many(
title: str,
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> list[int] | tuple[list[int], int | None]:
"""Arrow-key multi-select with checkboxes.
Returns:
Sorted list of selected indices, or ``(indices, action_index)`` when
``action_indices`` is provided.
"""
click.echo()
click.secho(f" {title}", fg="cyan")
if _is_interactive():
try:
selected, action = _arrow_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
initial_cursor=initial_cursor,
)
except Exception:
selected, action = _numbered_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
)
else:
selected, action = _numbered_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
)
if action_indices is None:
return selected
return selected, action

View File

@@ -24,6 +24,7 @@ __all__ = [
"build_env_with_all_tool_credentials",
"build_env_with_tool_repository_credentials",
"copy_template",
"enable_prompt_line_editing",
"fetch_and_json_env_file",
"get_project_description",
"get_project_name",
@@ -40,6 +41,19 @@ __all__ = [
console = Console()
def enable_prompt_line_editing() -> None:
"""Enable cursor movement/history editing for Click text prompts when available."""
try:
import readline
except ImportError:
return
try:
readline.parse_and_bind("set editing-mode emacs")
except Exception: # pragma: no cover - readline backends vary by platform
return
def copy_template(
src: Path, dst: Path, name: str, class_name: str, folder_name: str
) -> None:

View File

@@ -150,6 +150,7 @@ class TestDeployCommand(unittest.TestCase):
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository.origin_url")
@patch("builtins.input")
@pytest.mark.timeout(180)
def test_create_crew(self, mock_input, mock_git_origin_url, mock_fetch_env):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_git_origin_url.return_value = "https://github.com/test/repo.git"
@@ -165,6 +166,40 @@ class TestDeployCommand(unittest.TestCase):
self.assertIn("Deployment created successfully!", fake_out.getvalue())
self.assertIn("new-uuid", fake_out.getvalue())
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_without_git_repo_shows_setup_help(
self, mock_repository, mock_fetch_env
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.side_effect = ValueError("not a Git repository")
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(skip_validate=True)
output = fake_out.getvalue()
self.assertIn("Deployment requires a Git repository", output)
self.assertIn("git init", output)
self.assertIn("git remote add origin <your-repo-url>", output)
self.mock_client.create_crew.assert_not_called()
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_without_remote_shows_remote_help(
self, mock_repository, mock_fetch_env
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = None
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(skip_validate=True)
output = fake_out.getvalue()
self.assertIn("No remote repository URL found.", output)
self.assertIn("git remote add origin <your-repo-url>", output)
self.assertIn("git push -u origin HEAD", output)
self.mock_client.create_crew.assert_not_called()
def test_list_crews(self):
mock_response = MagicMock()
mock_response.status_code = 200

View File

@@ -110,6 +110,45 @@ def _run_without_import_check(root: Path) -> DeployValidator:
return v
def _scaffold_json_crew(root: Path, *, task_agent: str = "researcher") -> None:
(root / "pyproject.toml").write_text(_make_pyproject(name="json_crew"))
(root / "uv.lock").write_text("# dummy uv lockfile\n")
agents_dir = root / "agents"
agents_dir.mkdir()
(agents_dir / "researcher.jsonc").write_text(
dedent(
"""
{
"role": "Researcher",
"goal": "Research things",
"backstory": "Experienced researcher",
"llm": "openai/gpt-4o-mini"
}
"""
).strip()
+ "\n"
)
(root / "crew.jsonc").write_text(
dedent(
f"""
{{
"name": "json_crew",
"agents": ["researcher"],
"tasks": [
{{
"name": "research",
"description": "Research https://example.com/a//b",
"expected_output": "Findings",
"agent": "{task_agent}"
}}
]
}}
"""
).strip()
+ "\n"
)
@pytest.mark.parametrize(
"project_name, expected",
[
@@ -129,6 +168,38 @@ def test_valid_standard_crew_project_passes(tmp_path: Path) -> None:
assert v.ok, f"expected clean run, got {v.results}"
def test_valid_json_crew_project_passes(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path)
v = DeployValidator(project_root=tmp_path)
v.run()
assert "invalid_crew_json" not in _codes(v)
def test_json_task_agent_mismatch_is_error(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path, task_agent="missing_agent")
v = DeployValidator(project_root=tmp_path)
v.run()
finding = next(r for r in v.results if r.code == "invalid_crew_json")
assert finding.severity is Severity.ERROR
assert "missing_agent" in finding.detail
def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path)
crew_path = tmp_path / "crew.jsonc"
crew_path.write_text(
crew_path.read_text().replace(
'"name": "json_crew",',
'"name": "json_crew",\n "id": "00000000-0000-4000-8000-000000000000",',
)
)
v = DeployValidator(project_root=tmp_path)
v.run()
finding = next(r for r in v.results if r.code == "invalid_crew_json")
assert finding.severity is Severity.ERROR
assert "runtime-only" in finding.detail
def test_missing_pyproject_errors(tmp_path: Path) -> None:
v = _run_without_import_check(tmp_path)
assert "missing_pyproject" in _codes(v)
@@ -426,4 +497,31 @@ def test_create_crew_aborts_on_validation_error(tmp_path: Path) -> None:
cmd = DeployCommand()
cmd.create_crew()
assert not cmd.plus_api_client.create_crew.called
del mock_api # silence unused-var lint
del mock_api # silence unused-var lint
def test_is_json_crew_defers_to_declared_flow_type(tmp_path):
"""A flow project with a stray crew.jsonc must validate as a flow."""
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
'[tool.crewai]\ntype = "flow"\n'
)
assert DeployValidator(project_root=tmp_path)._is_json_crew is False
def test_is_json_crew_true_for_declared_crew_type(tmp_path):
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
'[tool.crewai]\ntype = "crew"\n'
)
assert DeployValidator(project_root=tmp_path)._is_json_crew is True
def test_is_json_crew_true_without_pyproject(tmp_path):
(tmp_path / "crew.jsonc").write_text("{}")
assert DeployValidator(project_root=tmp_path)._is_json_crew is True

View File

@@ -94,9 +94,9 @@ def test_version_command_with_tools(runner):
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)
evaluate_crew.assert_called_once_with(3, "gpt-5.4-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
assert "Testing the crew for 3 iterations with model gpt-5.4-mini" in result.output
@mock.patch("crewai_cli.cli.evaluate_crew")

View File

@@ -6,6 +6,8 @@ from unittest import mock
import pytest
from click.testing import CliRunner
import crewai_cli.create_json_crew as json_crew
import crewai_cli.tui_picker as tui_picker
from crewai_cli.create_crew import create_crew, create_folder_structure
@@ -345,3 +347,441 @@ def test_env_vars_are_uppercased_in_env_file(
env_file_path = crew_path / ".env"
content = env_file_path.read_text()
assert "MODEL=" in content
def test_json_wizard_defaults_to_sequential_and_memory_enabled(monkeypatch):
monkeypatch.setattr(
json_crew,
"_wizard_agent",
lambda **_: {
"name": "researcher",
"role": "Researcher",
"goal": "Research",
"backstory": "Researcher",
"llm": "openai/gpt-5.5",
"tools": [],
"planning": False,
"allow_delegation": False,
},
)
monkeypatch.setattr(
json_crew,
"_wizard_task",
lambda **_: {
"name": "research_task",
"description": "Research",
"expected_output": "Findings",
"agent": "researcher",
"context": [],
},
)
def confirm(label: str, default: bool = False) -> bool:
if label == "Enable crew memory?":
return default
return False
monkeypatch.setattr(json_crew, "_confirm", confirm)
monkeypatch.setattr(json_crew.click, "prompt", lambda *_, **__: "")
monkeypatch.setattr(
json_crew,
"pick_one",
lambda *_args, **_kwargs: pytest.fail("process should not be prompted"),
)
_agents, _tasks, settings = json_crew._wizard_agents_and_tasks(
skip_provider=True,
default_llm="openai/gpt-5.5",
)
assert settings == {"process": "sequential", "memory": True, "inputs": {}}
def test_json_wizard_shows_interpolation_hint(capsys):
json_crew._show_interpolation_hint("tasks")
output = capsys.readouterr().out
assert "{placeholder}" in output
assert "dynamic values" in output
assert "{topic}" not in output
assert "Description >" not in output
assert '"description"' not in output
def test_json_wizard_text_prompt_uses_full_prompt_for_readline(monkeypatch):
prompts: list[str] = []
monkeypatch.setattr(
json_crew, "_readline_safe_prompt", lambda prompt: f"safe:{prompt}"
)
monkeypatch.setattr(
"builtins.input", lambda prompt: prompts.append(prompt) or "Draft content"
)
assert json_crew._prompt_text("Goal", spacing_before=False) == "Draft content"
assert len(prompts) == 1
assert prompts[0].startswith("safe:")
assert "Goal" in prompts[0]
assert " > " in prompts[0]
def test_json_wizard_tool_picker_prioritizes_common_tools(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
return [1, 3], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
tools = json_crew._select_tools()
assert tools == ["SerperDevTool", "DirectoryReadTool"]
assert len(picker_calls) == 1
labels = picker_calls[0][1]
assert 0 in picker_calls[0][2]["separator_indices"]
assert labels[0] == "── Common tools ──"
assert labels[1].strip().endswith("SerperDevTool")
assert labels[2].strip().endswith("ScrapeWebsiteTool")
assert labels[3].strip().endswith("DirectoryReadTool")
assert labels[4].strip().endswith("FileReadTool")
assert labels[5].strip().endswith("FileWriterTool")
assert labels[1].index("Google search") < labels[1].index("SerperDevTool")
assert "More tools" not in labels
def test_json_wizard_tool_picker_collapses_categories_by_default(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
return [], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
json_crew._select_tools()
labels = picker_calls[0][1]
action_indices = picker_calls[0][2]["action_indices"]
# Categories show as collapsed action rows, not separators with tools
assert any(label.startswith("▸ Search & Research") for label in labels)
assert any(label.startswith("▸ Web Scraping") for label in labels)
assert not any(label.strip().endswith("BraveSearchTool") for label in labels)
assert len(action_indices) >= 4
# Only the common tools section is visible beyond the category rows
assert len(labels) == 1 + 5 + len(action_indices)
def test_json_wizard_tool_picker_expands_one_category_at_a_time(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def find_category_row(labels: list[str], category: str) -> int:
return next(
idx for idx, label in enumerate(labels) if category in label
)
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
call_num = len(picker_calls)
if call_num == 1:
return [], find_category_row(labels, "Search & Research")
if call_num == 2:
# Search & Research is expanded; select BraveSearchTool and
# expand Web Scraping instead
brave = next(
idx
for idx, label in enumerate(labels)
if label.strip().endswith("BraveSearchTool")
)
return [brave], find_category_row(labels, "Web Scraping")
return [], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
tools = json_crew._select_tools()
assert tools == ["BraveSearchTool"]
assert len(picker_calls) == 3
# Second render: Search & Research expanded, others collapsed
labels2 = picker_calls[1][1]
assert any(label.startswith("▾ Search & Research") for label in labels2)
assert any(label.strip().endswith("BraveSearchTool") for label in labels2)
assert any(label.startswith("▸ Web Scraping") for label in labels2)
# Third render: Web Scraping expanded, Search & Research collapsed again
labels3 = picker_calls[2][1]
assert any(label.startswith("▸ Search & Research") for label in labels3)
assert any(label.startswith("▾ Web Scraping") for label in labels3)
assert not any(label.strip().endswith("BraveSearchTool") for label in labels3)
# The collapsed Search & Research row reports its selection count
assert any(
"Search & Research" in label and "1 selected" in label for label in labels3
)
# Cursor returns to the toggled category row
assert picker_calls[2][2]["initial_cursor"] == next(
idx for idx, label in enumerate(labels3) if "Web Scraping" in label
)
def test_json_wizard_tool_picker_preserves_selection_across_renders(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
call_num = len(picker_calls)
if call_num == 1:
# Select a common tool, then expand a category
category_row = next(
idx for idx, label in enumerate(labels) if "Web Scraping" in label
)
return [1], category_row
# Confirm without touching anything else
return sorted(kwargs["preselected"]), None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
tools = json_crew._select_tools()
# The common-tool selection survived the expand re-render via preselected
assert tools == ["SerperDevTool"]
assert 1 in picker_calls[1][2]["preselected"]
def test_json_wizard_tool_picker_lists_builtin_tools_across_categories(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
expanded_labels: list[str] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
expanded_labels.extend(labels)
action_indices = sorted(kwargs["action_indices"])
call_num = len(picker_calls)
if call_num <= len(action_indices):
# Expand the n-th category (indices shift between renders, so
# recompute from this render's action rows)
return [], action_indices[call_num - 1]
return [], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
json_crew._select_tools()
tool_names = {
label.rsplit(maxsplit=1)[-1]
for label in expanded_labels
if not label.startswith(("", "", "──"))
}
assert {
"DirectorySearchTool",
"MDXSearchTool",
"XMLSearchTool",
"YoutubeVideoSearchTool",
"S3ReaderTool",
"E2BExecTool",
"TavilyResearchTool",
"SerplyNewsSearchTool",
"BrowserbaseLoadTool",
"PatronusEvalTool",
}.issubset(tool_names)
assert {
"MCPServerAdapter",
"MongoDBVectorSearchConfig",
"ScrapegraphScrapeToolSchema",
"SnowflakeConfig",
}.isdisjoint(tool_names)
def test_multi_picker_skips_separator_on_initial_cursor(monkeypatch):
cursors: list[int] = []
monkeypatch.setattr(tui_picker, "_read_key", lambda: "enter")
monkeypatch.setattr(
tui_picker,
"_draw_multi",
lambda _labels, cursor, *_args, **_kwargs: cursors.append(cursor),
)
monkeypatch.setattr(tui_picker, "_clear_lines", lambda *_args, **_kwargs: None)
assert tui_picker._arrow_select_multi(
["── Common tools ──", "Google search via Serper API SerperDevTool"],
separator_indices={0},
) == ([], None)
assert cursors == [1]
def test_json_wizard_agent_attribute_prompts_are_compact(monkeypatch):
prompt_calls: list[tuple[str, bool]] = []
prompt_values = {
"Role": "Senior Dev Rel",
"Goal": "Draft content",
"Backstory": "Knows developer communities",
}
def prompt_text(
label: str,
default: str = "",
*,
spacing_before: bool = True,
) -> str:
prompt_calls.append((label, spacing_before))
return prompt_values[label]
monkeypatch.setattr(json_crew, "_prompt_text", prompt_text)
monkeypatch.setattr(json_crew, "_select_model", lambda: "openai/gpt-5.5")
monkeypatch.setattr(json_crew, "pick_many", lambda *_args, **_kwargs: ([], None))
monkeypatch.setattr(json_crew, "_confirm", lambda *_args, **_kwargs: False)
agent = json_crew._wizard_agent(agent_num=1, existing_names=[])
assert agent is not None
assert prompt_calls == [
("Role", False),
("Goal", False),
("Backstory", False),
]
def test_json_wizard_task_attribute_prompts_are_compact(monkeypatch):
prompt_calls: list[tuple[str, bool]] = []
prompt_values = {
"Description": "Research latest release",
"Expected output": "Release summary",
}
def prompt_text(
label: str,
default: str = "",
*,
spacing_before: bool = True,
) -> str:
prompt_calls.append((label, spacing_before))
return prompt_values[label]
monkeypatch.setattr(json_crew, "_prompt_text", prompt_text)
task = json_crew._wizard_task(
task_num=1,
agent_names=["senior_dev_rel"],
prior_task_names=[],
)
assert task is not None
assert prompt_calls == [
("Description", False),
("Expected output", False),
]
def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
with mock.patch(
"crewai_cli.create_json_crew._wizard_agents_and_tasks"
) as mock_wizard:
mock_wizard.return_value = (
[
{
"name": "researcher",
"role": "Researcher",
"goal": "Research",
"backstory": "Researcher",
"llm": "openai/gpt-5.5",
"tools": [],
"planning": False,
"allow_delegation": False,
}
],
[
{
"name": "research_task",
"description": "Research",
"expected_output": "Findings",
"agent": "researcher",
"context": [],
}
],
{"process": "sequential", "memory": False, "inputs": {}},
)
json_crew.create_json_crew("JSON Crew", provider="openai", skip_provider=True)
mock_wizard.assert_called_once_with(
skip_provider=True,
default_llm="openai/gpt-5.5",
)
assert (tmp_path / "json_crew" / "crew.jsonc").exists()
assert not (tmp_path / "json_crew" / "tests").exists()
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text()
assert (
'"guardrail": "Every factual claim needs context support."'
in crew_template
)
assert '"guardrails": [' in crew_template
assert '"guardrail_max_retries": 2' in crew_template
assert "Docs: https://docs.crewai.com/concepts/tasks" in crew_template
assert '"output_pydantic": null' in crew_template
assert '"markdown": false' in crew_template
assert "Docs: https://docs.crewai.com/concepts/crews" in crew_template
assert '"manager_agent": "researcher"' in crew_template
assert '"output_log_file": "crew.log"' in crew_template
assert "Crew-level LLM fields also accept object form" in crew_template
assert '"chat_llm": {"model": "llama3", "provider": "ollama"' in (
crew_template
)
assert "Use {placeholder} in agent or task text" in crew_template
assert "`crewai run` prompts for any placeholders" in crew_template
assert "Use {placeholder} inputs here" in crew_template
agent_template = (
tmp_path / "json_crew" / "agents" / "researcher.jsonc"
).read_text()
assert "You can use {placeholder} inputs in role, goal, or backstory" in (
agent_template
)
assert '"role": "Senior {industry} Researcher"' in agent_template
assert "Optional agent-level guardrail" in agent_template
assert '"guardrail_max_retries": 2' in agent_template
assert "Docs: https://docs.crewai.com/concepts/agents" in agent_template
assert '"reasoning": true' in agent_template
assert "For custom endpoints or deployment-based providers" in agent_template
assert '"deployment_name": "my-deployment", "provider": "azure"' in (
agent_template
)
assert '"planning_config": {' in agent_template
assert '"llm": {"model": "deepseek-chat", "provider": "deepseek"}' in (
agent_template
)
assert '"knowledge_sources": []' in agent_template
def test_json_provider_default_model_helper():
assert json_crew._default_model_for_provider("openai") == "openai/gpt-5.5"
assert json_crew._default_model_for_provider("anthropic/claude-custom") == (
"anthropic/claude-custom"
)
assert json_crew._default_model_for_provider("unknown") is None
def test_json_wizard_task_reprompts_on_cancelled_agent_pick(monkeypatch):
"""Esc on the agent picker must reprompt, not silently assign agent 0."""
prompts = iter(["Do the research", "A report"])
monkeypatch.setattr(json_crew, "_prompt_text", lambda *a, **k: next(prompts))
pick_calls: list[str] = []
picks = iter([-1, 1])
def fake_pick_one(title: str, labels: list[str]) -> int:
pick_calls.append(title)
return next(picks)
monkeypatch.setattr(json_crew, "pick_one", fake_pick_one)
task = json_crew._wizard_task(
task_num=1,
agent_names=["first_agent", "second_agent"],
prior_task_names=[],
)
assert len(pick_calls) == 2
assert task["agent"] == "second_agent"

View File

@@ -0,0 +1,796 @@
from datetime import datetime
import time
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.observation_events import (
GoalAchievedEarlyEvent,
PlanRefinementEvent,
PlanReplanTriggeredEvent,
PlanStepCompletedEvent,
PlanStepStartedEvent,
StepObservationCompletedEvent,
StepObservationFailedEvent,
StepObservationStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai_cli import run_crew
from crewai_cli.crew_run_tui import CrewRunApp
def _app_with_plan() -> CrewRunApp:
app = CrewRunApp()
app._plan = {
"plan": "Demo plan",
"steps": [
{"step_number": 1, "description": "First"},
{"step_number": 2, "description": "Second"},
{"step_number": 3, "description": "Third"},
],
}
app._plan_step_status = {1: "pending", 2: "pending", 3: "pending"}
return app
def _log_entry(name: str) -> dict:
now = time.time()
return {
"tool_name": name,
"status": "success",
"args": None,
"result": f"{name} result",
"error": None,
"start_time": now,
"duration": 1.0,
"task_idx": 1,
}
def _emit_event(event: object) -> None:
future = crewai_event_bus.emit(None, event)
if future:
future.result(timeout=5)
def test_chain_deploy_skips_validation_after_auth_retry(monkeypatch) -> None:
create_calls: list[dict[str, object]] = []
login_calls: list[bool] = []
class FakeDeployCommand:
attempts = 0
def create_crew(self, **kwargs) -> None:
create_calls.append(kwargs)
FakeDeployCommand.attempts += 1
if FakeDeployCommand.attempts == 1:
raise SystemExit(1)
class FakeAuthenticationCommand:
def login(self) -> None:
login_calls.append(True)
monkeypatch.setattr("crewai_cli.deploy.main.DeployCommand", FakeDeployCommand)
monkeypatch.setattr(
"crewai_cli.authentication.main.AuthenticationCommand",
FakeAuthenticationCommand,
)
run_crew._chain_deploy()
assert create_calls == [
{"confirm": False, "skip_validate": True},
{"confirm": False, "skip_validate": True},
]
assert login_calls == [True]
def test_plan_step_status_updates_only_the_explicit_step() -> None:
app = _app_with_plan()
app._set_plan_step_status(2, "done")
assert app._plan_step_status == {
1: "pending",
2: "done",
3: "pending",
}
def test_step_observation_events_update_the_explicit_step() -> None:
app = _app_with_plan()
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
StepObservationStartedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
),
)
if future:
future.result(timeout=5)
assert app._plan_step_status == {
1: "pending",
2: "active",
3: "pending",
}
future = crewai_event_bus.emit(
None,
StepObservationCompletedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
step_completed_successfully=True,
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "pending",
2: "done",
3: "pending",
}
def test_plan_step_lifecycle_events_update_the_explicit_step() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
PlanStepStartedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
)
)
assert app._plan_step_status == {
1: "pending",
2: "active",
3: "pending",
}
_emit_event(
PlanStepCompletedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
success=True,
result="done",
)
)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "pending",
2: "done",
3: "pending",
}
def test_failed_plan_step_lifecycle_event_marks_exact_step_failed() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
PlanStepCompletedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
success=False,
error="Step failed",
)
)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "pending",
2: "failed",
3: "pending",
}
def test_tool_usage_events_do_not_advance_plan_steps() -> None:
app = _app_with_plan()
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
ToolUsageStartedEvent(tool_name="search", tool_args={"query": "CrewAI"}),
)
if future:
future.result(timeout=5)
now = datetime.now()
future = crewai_event_bus.emit(
None,
ToolUsageFinishedEvent(
tool_name="search",
tool_args={"query": "CrewAI"},
started_at=now,
finished_at=now,
output="result",
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "pending",
2: "pending",
3: "pending",
}
def test_next_tool_does_not_mark_unfinished_tool_successful() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
ToolUsageStartedEvent(tool_name="search", tool_args={"query": "CrewAI"}),
)
_emit_event(
ToolUsageStartedEvent(tool_name="scrape", tool_args={"url": "https://x"}),
)
finally:
app._unsubscribe()
assert app._log_entries[0]["status"] == "timeout"
assert app._log_entries[0]["result"] is None
assert app._log_entries[0]["error"] == (
"No result received before the next tool started"
)
assert app._log_entries[1]["status"] == "running"
assert app._plan_step_status == {
1: "pending",
2: "pending",
3: "pending",
}
def test_internal_reasoning_function_call_is_hidden_from_activity_log() -> None:
app = _app_with_plan()
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
ToolUsageStartedEvent(
tool_name="create_reasoning_plan",
tool_args={"plan": "Plan", "steps": [], "ready": True},
),
)
if future:
future.result(timeout=5)
now = datetime.now()
future = crewai_event_bus.emit(
None,
ToolUsageFinishedEvent(
tool_name="create_reasoning_plan",
tool_args={"plan": "Plan", "steps": [], "ready": True},
started_at=now,
finished_at=now,
output='{"plan":"Plan","steps":[],"ready":true}',
),
)
if future:
future.result(timeout=5)
future = crewai_event_bus.emit(
None,
ToolUsageErrorEvent(
tool_name="create_reasoning_plan",
tool_args={"plan": "Plan", "steps": [], "ready": True},
error="internal planning fallback",
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._log_entries == []
assert app._current_task_steps == []
def test_tool_failure_does_not_override_successful_plan_step_completion() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
PlanStepStartedEvent(
agent_role="Agent",
step_number=1,
step_description="First",
)
)
_emit_event(
ToolUsageStartedEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
plan_step_number=1,
plan_step_description="First",
)
)
_emit_event(
ToolUsageErrorEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
plan_step_number=1,
plan_step_description="First",
error="No results",
)
)
_emit_event(
PlanStepCompletedEvent(
agent_role="Agent",
step_number=1,
step_description="First",
success=True,
result="Recovered with another source",
)
)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "done",
2: "pending",
3: "pending",
}
def test_tool_event_step_metadata_is_stored_in_activity_log() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
ToolUsageStartedEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
plan_step_number=2,
plan_step_description="Second",
)
)
now = datetime.now()
_emit_event(
ToolUsageFinishedEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
plan_step_number=2,
plan_step_description="Second",
started_at=now,
finished_at=now,
output="Found official source",
)
)
finally:
app._unsubscribe()
assert app._log_entries[0]["plan_step_number"] == 2
assert app._plan_step_status == {
1: "pending",
2: "pending",
3: "pending",
}
def test_starting_next_tool_does_not_infer_plan_step_progress() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
ToolUsageStartedEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
)
)
_emit_event(
ToolUsageErrorEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
error="No results",
)
)
_emit_event(
ToolUsageStartedEvent(
tool_name="read_website_content",
tool_args={"url": "https://example.com"},
)
)
finally:
app._unsubscribe()
assert app._log_entries[0]["status"] == "error"
assert app._log_entries[1]["status"] == "running"
assert app._plan_step_status == {
1: "pending",
2: "pending",
3: "pending",
}
@pytest.mark.asyncio
async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None:
app = _app_with_plan()
async with app.run_test(size=(100, 40)) as pilot:
app._plan_step_status = {1: "failed", 2: "done", 3: "pending"}
app._log_entries = [
{
"tool_name": "search",
"status": "running",
"args": '{"query": "CrewAI"}',
"result": None,
"error": None,
"start_time": time.time() - 2,
"duration": None,
"task_idx": 1,
}
]
app._on_crew_done("final output")
await pilot.pause()
assert app._log_entries[0]["status"] == "timeout"
assert app._log_entries[0]["result"] is None
assert app._log_entries[0]["error"] == "No result received before crew completed"
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
def test_streamed_step_observation_updates_named_step_only() -> None:
app = _app_with_plan()
updated = app._try_parse_step_observation(
'{"step_completed_successfully":true,'
'"key_information_learned":"Step 2 succeeded with the official source."}'
)
assert updated is True
assert app._plan_step_status == {
1: "pending",
2: "done",
3: "pending",
}
def test_failed_streamed_step_observation_marks_named_step_failed() -> None:
app = _app_with_plan()
updated = app._try_parse_step_observation(
'{"step_completed_successfully":false,'
'"key_information_learned":"Step 2 failed because the tool failed."}'
)
assert updated is True
assert app._plan_step_status == {
1: "pending",
2: "failed",
3: "pending",
}
def test_streamed_goal_achieved_observation_collapses_remaining_steps_done() -> None:
app = _app_with_plan()
updated = app._try_parse_step_observation(
'{"step_number":2,'
'"step_completed_successfully":true,'
'"key_information_learned":"Goal is already satisfied.",'
'"goal_already_achieved":true}'
)
assert updated is True
assert app._plan_step_status == {
1: "done",
2: "done",
3: "done",
}
def test_task_completion_collapses_pending_plan_steps_but_preserves_failed() -> None:
app = _app_with_plan()
app._plan_step_status = {1: "failed", 2: "done", 3: "pending"}
app._collapse_plan_on_task_done()
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
def test_observation_failure_collapses_to_done_because_executor_continues() -> None:
app = _app_with_plan()
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
StepObservationFailedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
error="observer timeout",
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "done",
2: "done",
3: "pending",
}
def test_goal_achieved_event_collapses_remaining_steps_done() -> None:
app = _app_with_plan()
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
GoalAchievedEarlyEvent(
agent_role="Agent",
step_number=2,
steps_completed=2,
steps_remaining=1,
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "done",
2: "done",
3: "done",
}
def test_replan_event_keeps_old_plan_until_next_streamed_plan_replaces_it() -> None:
app = _app_with_plan()
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
PlanReplanTriggeredEvent(
agent_role="Agent",
step_number=2,
replan_reason="Need updated sources",
replan_count=1,
completed_steps_preserved=1,
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan is not None
assert app._plan_step_status == {1: "pending", 2: "pending", 3: "pending"}
assert app._awaiting_replan is True
app._try_parse_plan(
'{"plan":"Updated plan","steps":['
'{"step_number":1,"description":"Updated first"},'
'{"step_number":2,"description":"Updated second"}]}'
)
assert app._plan == {
"plan": "Updated plan",
"steps": [
{"step_number": 1, "description": "Updated first"},
{"step_number": 2, "description": "Updated second"},
],
}
assert app._plan_step_status == {1: "pending", 2: "pending"}
assert app._awaiting_replan is False
def test_plan_refinement_updates_descriptions_without_new_statuses() -> None:
app = _app_with_plan()
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
PlanRefinementEvent(
agent_role="Agent",
step_number=2,
refined_step_count=1,
refinements=["Step 3: Write the final answer from verified facts"],
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "done",
2: "done",
3: "pending",
}
assert app._plan["steps"][2]["description"] == (
"Write the final answer from verified facts"
)
def test_step_observation_json_is_hidden_from_streaming_text() -> None:
app = _app_with_plan()
assert (
app._strip_step_observation_json(
'Visible before {"step_completed_successfully":true,'
'"key_information_learned":"Step 2 succeeded."} visible after'
)
== "Visible before visible after"
)
@pytest.mark.asyncio
async def test_completed_run_keeps_activity_log_keyboard_navigation_active() -> None:
app = CrewRunApp()
async with app.run_test(size=(100, 40)) as pilot:
app._log_entries = [_log_entry("search"), _log_entry("scrape")]
app._on_crew_done("final output")
await pilot.pause()
assert app.focused is app.query_one("#log-panel")
await pilot.press("down", "enter")
await pilot.pause()
assert app._log_cursor == 1
assert app._log_expanded == {1}
await pilot.press("up")
await pilot.pause()
assert app._log_cursor == 0
class _FakeTask:
fingerprint = None
def __init__(self, task_id: str, name: str) -> None:
self.id = task_id
self.name = name
self.description = name
def test_async_task_completion_marks_the_right_sidebar_row() -> None:
"""Overlapping tasks: completing task 1 while task 2 runs must not
mark task 2 done, and starting task 2 must not mark task 1 done."""
from crewai.events.types.task_events import TaskCompletedEvent, TaskStartedEvent
from crewai.tasks.task_output import TaskOutput
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
app._subscribe()
try:
task1 = _FakeTask("id-1", "first")
task2 = _FakeTask("id-2", "second")
for task in (task1, task2):
future = crewai_event_bus.emit(
None, TaskStartedEvent(context=None, task=task)
)
if future:
future.result(timeout=5)
# Both started: neither prematurely done
assert app._task_statuses == {1: "active", 2: "active"}
future = crewai_event_bus.emit(
None,
TaskCompletedEvent(
output=TaskOutput(description="first", raw="done", agent="a"),
task=task1,
),
)
if future:
future.result(timeout=5)
assert app._task_statuses == {1: "done", 2: "active"}
finally:
app._unsubscribe()
def test_pop_task_state_falls_back_to_current_task() -> None:
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
app._current_task_idx = 2
app._current_task_desc = "second"
class _Evt:
task = None
task_name = "unknown"
state = app._pop_task_state(_Evt())
assert state["idx"] == 2
assert state["desc"] == "second"
def test_overlapping_task_logs_keep_their_own_state() -> None:
"""Task 1 completing after task 2 started must log its own description,
agent, and output — and must not steal or reset task 2's stream state."""
from crewai.events.types.task_events import TaskCompletedEvent, TaskStartedEvent
from crewai.tasks.task_output import TaskOutput
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
app._subscribe()
try:
task1 = _FakeTask("id-1", "first")
task2 = _FakeTask("id-2", "second")
for task in (task1, task2):
future = crewai_event_bus.emit(
None, TaskStartedEvent(context=None, task=task)
)
if future:
future.result(timeout=5)
# Task 2 is current and has streamed state in flight
app._task_full_output = "task two streaming output"
app._current_task_steps = [{"type": "llm", "summary": "thinking"}]
future = crewai_event_bus.emit(
None,
TaskCompletedEvent(
output=TaskOutput(
description="first", raw="task one result", agent="a1"
),
task=task1,
),
)
if future:
future.result(timeout=5)
# Task 1's entry carries its own identity and output
entry1 = app._task_logs[-1]
assert entry1["idx"] == 1
assert entry1["desc"] == "first"
assert entry1["output"] == "task one result"
assert entry1["steps"] == []
# Task 2's in-flight stream state was not consumed or reset
assert app._task_full_output == "task two streaming output"
assert app._current_task_steps == [{"type": "llm", "summary": "thinking"}]
future = crewai_event_bus.emit(
None,
TaskCompletedEvent(
output=TaskOutput(
description="second", raw="task two result", agent="a2"
),
task=task2,
),
)
if future:
future.result(timeout=5)
entry2 = app._task_logs[-1]
assert entry2["idx"] == 2
assert entry2["desc"] == "second"
assert entry2["output"] == "task two streaming output"
assert any(step.get("summary") == "thinking" for step in entry2["steps"])
finally:
app._unsubscribe()

View File

@@ -0,0 +1,144 @@
"""Tests for crewai_cli.run_crew JSON crew handling."""
import os
from pathlib import Path
import pytest
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
import crewai_cli.run_crew as run_crew_module
def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
"""crewai run -f must reach JSON crews, not only classic subprocess crews."""
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True)
called: dict = {}
def fake_run_json_crew(trained_agents_file=None):
called["trained_agents_file"] = trained_agents_file
monkeypatch.setattr(run_crew_module, "_run_json_crew", fake_run_json_crew)
run_crew_module.run_crew(trained_agents_file="some.pkl")
assert called == {"trained_agents_file": "some.pkl"}
def test_run_json_crew_exports_trained_agents_env(monkeypatch, tmp_path: Path):
"""JSON crews run in-process, so the pickle path must land in the env var."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv(CREWAI_TRAINED_AGENTS_FILE_ENV, raising=False)
try:
# No crew.json(c) in tmp_path: the loader fails *after* the env var
# export, which is the part under test.
with pytest.raises(FileNotFoundError):
run_crew_module._run_json_crew(trained_agents_file="some.pkl")
assert os.environ[CREWAI_TRAINED_AGENTS_FILE_ENV] == "some.pkl"
finally:
os.environ.pop(CREWAI_TRAINED_AGENTS_FILE_ENV, None)
def test_run_json_crew_leaves_env_untouched_without_flag(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv(CREWAI_TRAINED_AGENTS_FILE_ENV, raising=False)
with pytest.raises(FileNotFoundError):
run_crew_module._run_json_crew()
assert CREWAI_TRAINED_AGENTS_FILE_ENV not in os.environ
def test_missing_input_names_accepts_hyphenated_placeholders():
"""The prompt regex must accept the same names kickoff interpolation does."""
from types import SimpleNamespace
crew = SimpleNamespace(
agents=[
SimpleNamespace(
role="Researcher", goal="Cover {my-topic}", backstory=""
)
],
tasks=[
SimpleNamespace(
description="Write about {my-topic} for {target-audience}",
expected_output="Post",
output_file=None,
)
],
)
assert run_crew_module._missing_input_names(crew, {}) == [
"my-topic",
"target-audience",
]
def _patch_tui_run(monkeypatch, status: str):
"""Stub the TUI pieces of _run_json_crew so only exit handling runs."""
class FakeApp:
def __init__(self, **kwargs):
self._status = status
self._crew_result = "result" if status == "completed" else None
self._want_deploy = False
def run(self):
pass
from types import SimpleNamespace
crew = SimpleNamespace(name="Demo", tasks=[], agents=[])
monkeypatch.setattr(
run_crew_module, "find_crew_json_file", lambda: Path("crew.jsonc")
)
monkeypatch.setattr(
run_crew_module,
"_load_json_crew_for_tui",
lambda _path: (FakeApp, crew, {}, [], []),
)
monkeypatch.setattr(
run_crew_module, "_prompt_for_missing_inputs", lambda _crew, inputs: inputs
)
monkeypatch.setattr(run_crew_module, "_print_post_tui_summary", lambda _app: None)
def test_run_json_crew_failed_status_exits_nonzero(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
_patch_tui_run(monkeypatch, status="failed")
with pytest.raises(SystemExit) as exc_info:
run_crew_module._run_json_crew()
assert exc_info.value.code == 1
def test_run_json_crew_completed_status_returns_result(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
_patch_tui_run(monkeypatch, status="completed")
assert run_crew_module._run_json_crew() == "result"
def test_has_json_crew_defers_to_declared_flow_type(monkeypatch, tmp_path: Path):
"""A flow project containing a stray crew.jsonc must still run as a flow."""
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "flow"\n')
assert run_crew_module._has_json_crew() is False
def test_has_json_crew_true_for_declared_crew_type(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "crew"\n')
assert run_crew_module._has_json_crew() is True
def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
assert run_crew_module._has_json_crew() is True

View File

@@ -157,14 +157,16 @@ 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.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):
@patch("crewai_cli.tools.main.git.Repository")
def test_publish_when_not_in_sync(mock_repository, capsys, tool_command):
mock_repository.return_value.is_synced.return_value = False
with raises(SystemExit):
tool_command.publish(is_public=True)
output = capsys.readouterr().out
assert "Local changes need to be resolved before publishing" in output
mock_repository.return_value.is_synced.assert_called_once_with()
@patch("crewai_cli.tools.main.get_project_name", return_value="sample-tool")