mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-12 01:12:09 +00:00
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (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: add project_id to link OSS usage to an enterprise account Adds a stable per-project identifier so a project's OSS traces and runs can be attributed to an account after signup. There was no such identifier before: [tool.crewai] held only `type`, the deploy UUID was printed to the console but never persisted, Settings.org_uuid is global rather than per-project, and trace batches carried only crew_fingerprint/crew_name. The id lives in the project's pyproject.toml, so it is committed with the repository and stays stable across machines, teammates, CI, and containers - unlike a machine- or user-derived identifier, which is unstable in exactly the containerized production environments that matter most. crewai-core: - get_project_id(): read-only lookup of [tool.crewai].project_id. Safe for library code; never creates or modifies anything. - get_or_create_project_id(): mints a uuid4 and persists it, returning (id, created) so callers can tell the user. Best-effort - returns (None, False) for a missing, malformed, or read-only pyproject.toml rather than raising. - Insertion edits the raw TOML text instead of round-tripping through a writer, so comments, key order, and formatting elsewhere survive. The key is placed at the end of the [tool.crewai] table, before the next table header, so it cannot land in a neighbouring section. - LoginPayload and TraceExecutionContext gain optional project_id. Sent on two paths: - Traces: project_id is added to execution_context, which is sent on both the ephemeral and authenticated paths, so a project's traces remain attributable before and after the user creates an account. - Login: `crewai login` already sends the pseudonymous user_identifier on an authenticated request; adding project_id means one request carries account + user + project, which is the link itself. Minting is restricted to CLI commands the user explicitly invoked - `crewai create` for new projects and `crewai run` to backfill existing ones - and is announced when it happens. Library code only ever reads. Silently rewriting a user's pyproject.toml during Crew.kickoff() would be surprising. Privacy: project_id is a random uuid4 in a file the user commits. It is visible in a diff, contains nothing personal, and identifies a project rather than a person - so this needs none of the notice changes that attaching a user identifier to all telemetry would require. Tests: 18 new tests covering minting, stability, table placement, comment and formatting preservation, five pyproject layouts, the neighbouring-table regression, and graceful handling of missing/malformed/read-only files. Verified end-to-end that both create paths mint distinct ids, that the trace payload carries project_id on both the ephemeral and authenticated paths, and that the login payload carries user_identifier and project_id together. Follow-ups, deliberately not included: adding project_id to telemetry spans, and backend persistence of the (account, user_identifier, project_id) triple. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t * refactor: drop the console announcement when minting project_id Minting now happens silently. With no message to print, the (id, created) tuple had no consumer, so simplify the API rather than keep the flag around for a hypothetical caller: - get_or_create_project_id() returns `str | None` instead of `tuple[str | None, bool]`. - Remove crewai_cli.utils.ensure_project_id, which existed only to print the message and discard the flag. The four call sites (crewai create crew, crewai create flow, crewai run, and tool-repository login) now call get_or_create_project_id directly. - Update tests for the simplified signature; still 18 tests covering minting, stability, table placement, formatting preservation, five pyproject layouts, and missing/malformed/read-only handling. Behaviour is otherwise unchanged: minting stays restricted to CLI commands the user invoked, library code still only reads via get_project_id, and a missing or read-only pyproject.toml still returns None rather than raising. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t * fix: harden project_id minting against TOML corruption; address review Several reviewers found ways the raw-text edit could produce invalid TOML. Each is now fixed and covered by a test that fails without the fix. Duplicate project_id key (Cursor bugbot, Copilot x2): - get_project_id() reports a blank or non-string value as "absent", so a file containing `project_id = ""` took the insert path and gained a second project_id line - a duplicate key, and therefore invalid TOML that no tomli-based tool could read afterwards. - _insert_project_id is now _set_project_id: it replaces an existing assignment inside [tool.crewai] instead of appending unconditionally. Table header with a trailing comment (CodeRabbit major, Cursor bugbot): - `[tool.crewai] # config` is valid TOML but failed exact string equality, so the fallback appended a second [tool.crewai] header - a redefined table, also invalid TOML, and silent because get_project_id swallows the resulting decode error. - Added _is_table_header(), which tolerates a trailing comment and does not match similar names such as [tool.crewai-extra]. Writing into malformed TOML (Cursor bugbot, Copilot): - get_or_create_project_id relied on get_project_id, which cannot distinguish "no id" from "unparsable file", so it appended to files it could not parse. - The locked path now parses explicitly and bails on a decode error, and re-parses the updated content before writing, so this feature can never be the reason a project's pyproject.toml stops parsing. Concurrency and atomicity (CodeRabbit major): - Two CLI processes could both see no id, mint different uuids, and clobber each other, leaving a caller holding an id that is not on disk. Minting now takes the existing crewai_core cross-process lock, re-reads under it, and returns the id that persists. - Writes go through a temp file in the same directory plus os.replace, so an interruption cannot truncate pyproject.toml. File mode is copied across, and the temp file is removed on failure. - os.replace only needs a writable directory, which would have let an atomic write silently overwrite a file the user marked read-only; writability is now checked explicitly so that case still returns None. Line endings (CodeRabbit): - Path.read_text/write_text normalized CRLF to LF, so minting would rewrite a CRLF-committed file entirely. Read and write now use newline="" and the inserted line ending is derived from the existing content. Default create path skipped minting (Cursor bugbot): - `crewai create crew` defaults to create_json_crew; only the --classic and flow paths minted, so most new projects had no id until a later command. Wired into create_json_crew as well. Verified all three paths now mint distinct ids. Do not mint during login (CodeRabbit major): - ToolCommand.login ran get_or_create_project_id, which is outside the sanctioned minting commands and is invoked by `crewai tools create` from a freshly scaffolded directory before the project is persisted. It now uses the read-only get_project_id. Verified login leaves pyproject.toml untouched. Not applied: Copilot asked for a console message when an id is written, in create_crew and create_flow. Minting was made deliberately silent in the previous commit, so the (id, created) tuple and the announcement are both gone by design. Tests: 32 in test_project_id.py, up from 18. New cases cover blank and non-string existing ids, three commented-header forms, similar table names, malformed input, CRLF and LF preservation, concurrent minting convergence, file-mode preservation, and temp-file cleanup. Confirmed the header and duplicate-key tests fail when the fixes are reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t * fix: never create [tool.crewai], treat whitespace ids as absent, harden test `crewai run` could rewrite unrelated projects (Cursor bugbot, high): - get_or_create_project_id ran before the cwd was established as a CrewAI project, and _set_project_id appended a [tool.crewai] table when none existed. Any directory with a pyproject.toml could therefore gain one - including on `crewai run --definition`, which may otherwise succeed. - _set_project_id no longer creates the table; it returns None when [tool.crewai] is absent, so a key is only ever added to a table the project already declares. The templates all ship the table, so no create path needs the old fallback. - The minting call in run_crew moved after the --definition early return, so an explicit-flow run does not touch the cwd at all. - Presence is checked, not truthiness: an empty [tool.crewai] is still a CrewAI marker, and get_crewai_project_config returns {} both for that and for an absent table. - Verified an unrelated project's pyproject.toml is byte-identical after a mint attempt. Whitespace-only project_id accepted as valid (CodeRabbit): - `project_id = " "` is truthy, so it was returned as an identity and would have propagated into login payloads and tracing context. It also meant the '" "' parameter of the replacement test asserted nothing. - Added _usable_project_id, which strips before deciding, used by both get_project_id and the locked mint path. Concurrency test could hang CI (CodeRabbit, major): - Neither the barrier nor the joins had timeouts, so a thread dying early or blocking on the lock would hang the job rather than fail it. The result count was also unchecked, so a dead thread still passed. - Added timeouts, an explicit liveness assertion, a result-count assertion, a lock around the shared result list, and corrected the docstring: this covers the read-modify-write race with threads, not the cross-process backend. Tests: 35, up from 32. New coverage for the absent-table refusal and three whitespace forms; the blank-id replacement case now asserts a real uuid replaced the blank value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t * chore(deps): force gitpython 3.1.57+ for GHSA-p538-c434-8v24 and GHSA-3f7w-8rr8-f37f Unrelated to project_id; bundled here only because it blocks this PR's vulnerability scan. Two advisories were published for gitpython 3.1.55 after main last passed the scan: - GHSA-p538-c434-8v24: arbitrary file truncation via `git rev-list --output` argument injection. Fixed in 3.1.56. - GHSA-3f7w-8rr8-f37f: unguarded git option forwarding in IndexFile.checkout() and TagReference. Fixed in 3.1.57. - Bump the override floor to gitpython>=3.1.57 and declare the same floor in crewai-tools, so consumers installing the published package are covered and not only this repo's lock. - 3.1.57 was published 2026-07-26, past gitpython's exclude-newer-package cutoff of 2026-07-24, so that cutoff moves to 2026-07-27. Without it the floor is unresolvable. pip-audit against the updated lock reports no known vulnerabilities. Verified gitpython 3.1.57 resolves and that crewai_tools and crewai_cli.git still import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
800 lines
27 KiB
Python
800 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
from contextlib import AbstractContextManager, nullcontext
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import click
|
|
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
|
from packaging import version
|
|
|
|
from crewai_cli.input_prompt import (
|
|
closest_name,
|
|
is_interactive,
|
|
parse_inputs_json,
|
|
prompt_for_inputs,
|
|
)
|
|
from crewai_cli.utils import (
|
|
build_env_with_all_tool_credentials,
|
|
get_or_create_project_id,
|
|
is_dmn_mode_enabled,
|
|
)
|
|
from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from crewai_cli.crew_run_tui import CrewRunApp
|
|
|
|
|
|
# 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_\-]*)}(?!})")
|
|
_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV = "CREWAI_CLI_RUNNER_PACKAGE_DIR"
|
|
_CREWAI_RUNNER_SOURCE_DIR_ENV = "CREWAI_RUNNER_SOURCE_DIR"
|
|
_CREWAI_JSON_CREW_DEFINITION_ENV = "CREWAI_JSON_CREW_DEFINITION"
|
|
_CREWAI_JSON_CREW_INPUTS_ENV = "CREWAI_JSON_CREW_INPUTS"
|
|
_FULL_CREWAI_INSTALL_MESSAGE = f"""\
|
|
CrewAI CLI is installed without the `crewai` package required to run crews.
|
|
|
|
Install the full CrewAI package:
|
|
|
|
uv tool install --force '{get_crewai_tools_dependency()}'
|
|
|
|
The quotes are required in zsh so `crewai[tools]` is not treated as a glob.
|
|
"""
|
|
_JSON_CREW_RUNNER_CODE = """
|
|
import importlib.util
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
source_dir = os.environ.get("CREWAI_RUNNER_SOURCE_DIR")
|
|
if source_dir:
|
|
sys.path.insert(0, source_dir)
|
|
|
|
package_dir = Path(os.environ["CREWAI_CLI_RUNNER_PACKAGE_DIR"])
|
|
package_spec = importlib.util.spec_from_file_location(
|
|
"crewai_cli",
|
|
package_dir / "__init__.py",
|
|
submodule_search_locations=[str(package_dir)],
|
|
)
|
|
if package_spec is None or package_spec.loader is None:
|
|
raise ImportError(f"Cannot load CrewAI CLI package from {package_dir}")
|
|
|
|
package = importlib.util.module_from_spec(package_spec)
|
|
sys.modules["crewai_cli"] = package
|
|
package_spec.loader.exec_module(package)
|
|
|
|
module_path = package_dir / "run_crew.py"
|
|
module_spec = importlib.util.spec_from_file_location("crewai_cli.run_crew", module_path)
|
|
if module_spec is None or module_spec.loader is None:
|
|
raise ImportError(f"Cannot load CrewAI CLI runner from {module_path}")
|
|
|
|
module = importlib.util.module_from_spec(module_spec)
|
|
sys.modules["crewai_cli.run_crew"] = module
|
|
module_spec.loader.exec_module(module)
|
|
|
|
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
|
|
|
kwargs = {
|
|
"trained_agents_file": os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV),
|
|
}
|
|
if crew_definition := os.getenv("CREWAI_JSON_CREW_DEFINITION"):
|
|
kwargs["crew_path"] = crew_definition
|
|
if crew_inputs := os.getenv("CREWAI_JSON_CREW_INPUTS"):
|
|
kwargs["inputs"] = crew_inputs
|
|
|
|
try:
|
|
module._run_json_crew(**kwargs)
|
|
except module.click.ClickException as exc:
|
|
exc.show()
|
|
raise SystemExit(exc.exit_code)
|
|
""".strip()
|
|
|
|
|
|
def _is_missing_crewai_package(exc: ModuleNotFoundError) -> bool:
|
|
return bool(exc.name and exc.name.startswith("crewai"))
|
|
|
|
|
|
def _full_crewai_install_error() -> click.ClickException:
|
|
return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE)
|
|
|
|
|
|
def read_toml(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
from crewai_core.project import read_toml as _read_toml
|
|
|
|
return _read_toml(*args, **kwargs)
|
|
|
|
|
|
def get_crewai_project_type(pyproject_data: dict[str, Any]) -> str | None:
|
|
from crewai_core.project import get_crewai_project_type as _get_crewai_project_type
|
|
|
|
return _get_crewai_project_type(pyproject_data)
|
|
|
|
|
|
def configured_project_json_crew(
|
|
pyproject_data: dict[str, Any] | None = None,
|
|
project_root: Path | None = None,
|
|
) -> Path | None:
|
|
"""Return the configured JSON crew definition for crew projects."""
|
|
from crewai_core.project import (
|
|
ProjectDefinitionError,
|
|
configured_project_definition,
|
|
)
|
|
|
|
root = project_root or Path.cwd()
|
|
if pyproject_data is None and not (root / "pyproject.toml").is_file():
|
|
return None
|
|
|
|
try:
|
|
return configured_project_definition(
|
|
"crew",
|
|
pyproject_data=pyproject_data,
|
|
project_root=root,
|
|
)
|
|
except ProjectDefinitionError as exc:
|
|
raise click.UsageError(str(exc)) from exc
|
|
|
|
|
|
def _extract_input_placeholders(text: str | None) -> set[str]:
|
|
if not text:
|
|
return set()
|
|
return set(_INPUT_PLACEHOLDER_RE.findall(text))
|
|
|
|
|
|
def _referenced_input_names(crew: Any) -> set[str]:
|
|
"""All ``{placeholder}`` names referenced by a crew's agents and tasks."""
|
|
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 placeholders
|
|
|
|
|
|
def _missing_input_names(crew: Any, inputs: dict[str, Any]) -> list[str]:
|
|
"""Return input placeholders referenced by a crew but not provided as inputs."""
|
|
return sorted(name for name in _referenced_input_names(crew) if name not in inputs)
|
|
|
|
|
|
def _resolve_crew_inputs(
|
|
crew: Any,
|
|
default_inputs: dict[str, Any],
|
|
provided: dict[str, Any] | None,
|
|
*,
|
|
interactive: bool,
|
|
) -> dict[str, Any]:
|
|
"""Resolve kickoff inputs for a declarative crew.
|
|
|
|
Mirrors the declarative-flow experience (``_resolve_flow_inputs``): layers
|
|
``--inputs`` over the crew's declared ``inputs`` defaults, warns on provided
|
|
keys that aren't referenced as ``{placeholder}``s, prompts for any
|
|
still-missing placeholders when interactive, and exits with a pointed
|
|
message when one is still missing.
|
|
|
|
Unlike flows — whose state schema is an authoritative contract, so unknown
|
|
keys are dropped — the crew placeholder scan is heuristic (it only covers
|
|
agent/task text fields). An unrecognized key is therefore warned about but
|
|
*kept*, never dropped: dropping could silently discard a value that a field
|
|
the scan doesn't cover actually relies on.
|
|
"""
|
|
referenced = _referenced_input_names(crew)
|
|
inputs = dict(default_inputs or {})
|
|
|
|
for key, value in (provided or {}).items():
|
|
if key not in referenced:
|
|
suggestion = closest_name(key, referenced)
|
|
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
|
|
click.secho(
|
|
f" Input '{key}' isn't referenced by any {{placeholder}} "
|
|
f"in the crew.{hint}",
|
|
fg="yellow",
|
|
err=True,
|
|
)
|
|
inputs[key] = value
|
|
|
|
missing = _missing_input_names(crew, inputs)
|
|
if missing and interactive:
|
|
inputs.update(
|
|
prompt_for_inputs(
|
|
missing,
|
|
title="Crew inputs",
|
|
subtitle="This crew needs the following to run.",
|
|
)
|
|
)
|
|
missing = _missing_input_names(crew, inputs)
|
|
|
|
if missing:
|
|
for name in missing:
|
|
click.secho(f" Missing required input '{name}'", fg="red", err=True)
|
|
click.secho(
|
|
" Provide them via --inputs or the `inputs` object in crew.json(c).",
|
|
dim=True,
|
|
err=True,
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
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]]:
|
|
try:
|
|
from crewai.project.crew_loader import load_crew
|
|
except ModuleNotFoundError as exc:
|
|
if _is_missing_crewai_package(exc):
|
|
raise _full_crewai_install_error() from exc
|
|
raise
|
|
|
|
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_without_tui(crew_path: Path, provided: dict[str, Any] | None) -> Any:
|
|
"""Run a JSON-defined crew with plain terminal output."""
|
|
with _json_loading_status("Preparing crew..."):
|
|
crew, default_inputs = _load_json_crew(crew_path)
|
|
|
|
runtime_inputs = _resolve_crew_inputs(
|
|
crew, default_inputs, provided, interactive=False
|
|
)
|
|
result = crew.kickoff(inputs=runtime_inputs)
|
|
if result is not None:
|
|
click.echo(str(result))
|
|
return result
|
|
|
|
|
|
def _run_json_crew(
|
|
trained_agents_file: str | None = None,
|
|
crew_path: str | Path | None = None,
|
|
inputs: 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
|
|
|
|
if crew_path is None:
|
|
crew_path = configured_project_json_crew()
|
|
if crew_path is None:
|
|
raise FileNotFoundError(
|
|
"No JSON crew definition configured in [tool.crewai].definition"
|
|
)
|
|
crew_path = Path(crew_path)
|
|
|
|
provided = parse_inputs_json(inputs)
|
|
|
|
if is_dmn_mode_enabled():
|
|
return _run_json_crew_without_tui(crew_path, provided)
|
|
|
|
crew_run_app_cls, crew, default_inputs, task_names, agent_names = (
|
|
_load_json_crew_for_tui(crew_path)
|
|
)
|
|
runtime_inputs = _resolve_crew_inputs(
|
|
crew, default_inputs, provided, interactive=is_interactive()
|
|
)
|
|
|
|
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 _has_lockfile(project_root: Path | None = None) -> bool:
|
|
"""Return True when the project already has a dependency lockfile."""
|
|
return _has_uv_lockfile(project_root) or _has_poetry_lockfile(project_root)
|
|
|
|
|
|
def _has_uv_lockfile(project_root: Path | None = None) -> bool:
|
|
"""Return True when the project has a uv lockfile."""
|
|
root = project_root or Path.cwd()
|
|
return (root / "uv.lock").is_file()
|
|
|
|
|
|
def _has_poetry_lockfile(project_root: Path | None = None) -> bool:
|
|
"""Return True when the project has a Poetry lockfile."""
|
|
root = project_root or Path.cwd()
|
|
return (root / "poetry.lock").is_file()
|
|
|
|
|
|
def _uses_poetry_lockfile(project_root: Path | None = None) -> bool:
|
|
"""Return True when Poetry is the only available lock source."""
|
|
return _has_poetry_lockfile(project_root) and not _has_uv_lockfile(project_root)
|
|
|
|
|
|
def _has_project_venv(project_root: Path | None = None) -> bool:
|
|
"""Return True when the project already has a local uv environment."""
|
|
root = project_root or Path.cwd()
|
|
return (root / ".venv").is_dir()
|
|
|
|
|
|
def _install_json_crew_dependencies_if_needed() -> None:
|
|
"""Prepare JSON crew dependencies without mutating existing lockfiles."""
|
|
project_root = Path.cwd()
|
|
if not (project_root / "pyproject.toml").is_file():
|
|
return
|
|
|
|
has_uv_lockfile = _has_uv_lockfile(project_root)
|
|
has_lockfile = has_uv_lockfile or _has_poetry_lockfile(project_root)
|
|
if has_lockfile and _has_project_venv(project_root):
|
|
return
|
|
if _uses_poetry_lockfile(project_root):
|
|
return
|
|
|
|
from crewai_cli.install_crew import install_crew
|
|
|
|
try:
|
|
if has_uv_lockfile:
|
|
click.echo("Syncing dependencies from lockfile...")
|
|
install_crew(["--frozen"], raise_on_error=True)
|
|
else:
|
|
click.echo("Installing dependencies...")
|
|
install_crew([], raise_on_error=True)
|
|
except subprocess.CalledProcessError as e:
|
|
raise SystemExit(e.returncode) from e
|
|
except Exception as e:
|
|
raise SystemExit(1) from e
|
|
|
|
|
|
def _find_local_crewai_source_dir() -> Path | None:
|
|
"""Return the repo's CrewAI source dir when running from a source checkout."""
|
|
for parent in Path(__file__).resolve().parents:
|
|
candidate = parent / "lib" / "crewai" / "src"
|
|
if (candidate / "crewai" / "project" / "json_loader.py").is_file():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _json_crew_run_command(project_root: Path | None = None) -> list[str]:
|
|
"""Return the project-environment command for running JSON crews."""
|
|
if _uses_poetry_lockfile(project_root):
|
|
return ["poetry", "run", "python", "-c", _JSON_CREW_RUNNER_CODE]
|
|
return ["uv", "run", "--no-sync", "python", "-c", _JSON_CREW_RUNNER_CODE]
|
|
|
|
|
|
def _run_json_crew_in_project_env(
|
|
trained_agents_file: str | None = None,
|
|
crew_path: str | Path | None = None,
|
|
inputs: str | None = None,
|
|
) -> Any:
|
|
"""Run JSON crews from the project's uv-managed environment."""
|
|
# Validate --inputs up front so bad JSON fails before we spin up the uv env.
|
|
if inputs is not None:
|
|
parse_inputs_json(inputs)
|
|
|
|
if not (Path.cwd() / "pyproject.toml").is_file():
|
|
return _run_json_crew(
|
|
trained_agents_file=trained_agents_file,
|
|
crew_path=crew_path,
|
|
inputs=inputs,
|
|
)
|
|
|
|
_install_json_crew_dependencies_if_needed()
|
|
|
|
command = _json_crew_run_command()
|
|
env = build_env_with_all_tool_credentials()
|
|
env[_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV] = str(Path(__file__).resolve().parent)
|
|
if local_crewai_source_dir := _find_local_crewai_source_dir():
|
|
env[_CREWAI_RUNNER_SOURCE_DIR_ENV] = str(local_crewai_source_dir)
|
|
if trained_agents_file:
|
|
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
|
|
if crew_path is not None:
|
|
env[_CREWAI_JSON_CREW_DEFINITION_ENV] = str(crew_path)
|
|
if inputs is not None:
|
|
env[_CREWAI_JSON_CREW_INPUTS_ENV] = inputs
|
|
|
|
try:
|
|
subprocess.run( # noqa: S603
|
|
command,
|
|
capture_output=False,
|
|
text=True,
|
|
check=True,
|
|
env=env,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
raise SystemExit(e.returncode) from e
|
|
except Exception as e:
|
|
click.echo(f"An unexpected error occurred while running the JSON crew: {e}")
|
|
raise SystemExit(1) from e
|
|
|
|
return None
|
|
|
|
|
|
def _chain_deploy() -> None:
|
|
from rich.console import Console
|
|
|
|
console = Console()
|
|
|
|
def print_system_exit_failure(exc: SystemExit) -> None:
|
|
if isinstance(exc.code, int):
|
|
detail = f" with exit code {exc.code}"
|
|
elif exc.code:
|
|
detail = f": {exc.code}"
|
|
else:
|
|
detail = ""
|
|
console.print(f"\nDeploy failed{detail}\n", style="bold red")
|
|
|
|
try:
|
|
from crewai_cli.command import AuthenticationRequiredError
|
|
from crewai_cli.deploy.main import DeployCommand
|
|
|
|
console.print("\nStarting deployment…\n", style="bold #FF5A50")
|
|
DeployCommand().create_crew(confirm=True, skip_validate=True)
|
|
except AuthenticationRequiredError:
|
|
from crewai_cli.authentication.main import AuthenticationCommand
|
|
|
|
console.print()
|
|
AuthenticationCommand().login()
|
|
try:
|
|
DeployCommand().create_crew(confirm=True, skip_validate=True)
|
|
except AuthenticationRequiredError:
|
|
console.print(
|
|
"\nDeploy failed: authentication is still required.\n",
|
|
style="bold red",
|
|
)
|
|
except SystemExit as e:
|
|
print_system_exit_failure(e)
|
|
except Exception as e:
|
|
console.print(f"\nDeploy failed: {e}\n", style="bold red")
|
|
except SystemExit as e:
|
|
print_system_exit_failure(e)
|
|
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,
|
|
definition: str | None = None,
|
|
inputs: str | None = None,
|
|
) -> None:
|
|
"""Run the crew or flow.
|
|
|
|
Args:
|
|
trained_agents_file: Optional path to a trained-agents pickle produced
|
|
by ``crewai train -f``. When set, exported as
|
|
``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this
|
|
file instead of the default ``trained_agents_data.pkl``.
|
|
definition: Optional path to a declarative Flow definition.
|
|
inputs: Optional JSON object of runtime inputs for a declarative flow
|
|
or declarative (JSON) crew. Layered over the definition's own
|
|
defaults; missing required values are prompted for interactively.
|
|
"""
|
|
# --definition is a pure override: run that flow directly.
|
|
if definition is not None:
|
|
_run_explicit_declarative_flow(
|
|
definition=definition,
|
|
inputs=inputs,
|
|
trained_agents_file=trained_agents_file,
|
|
)
|
|
return
|
|
|
|
pyproject_data = read_toml()
|
|
|
|
# Backfills projects created before project_id existed. Only here, in a
|
|
# command the user explicitly invoked - never from the SDK during kickoff.
|
|
# Placed after the --definition early return so an explicit-flow run does
|
|
# not touch the cwd; get_or_create_project_id itself refuses to act unless
|
|
# [tool.crewai] is already present, so an unrelated project is never
|
|
# rewritten.
|
|
get_or_create_project_id()
|
|
|
|
if json_crew_definition := configured_project_json_crew(pyproject_data):
|
|
# Declarative (JSON) crews resolve inputs the same way flows do: --inputs
|
|
# layers over the crew's declared defaults, missing {placeholder}s are
|
|
# prompted for, and unknown keys are flagged. Forward the raw JSON.
|
|
_run_json_crew_in_project_env(
|
|
trained_agents_file=trained_agents_file,
|
|
crew_path=json_crew_definition,
|
|
inputs=inputs,
|
|
)
|
|
return
|
|
|
|
_warn_if_old_poetry_project(pyproject_data)
|
|
project_type = get_crewai_project_type(pyproject_data)
|
|
|
|
if project_type == "flow":
|
|
# No --definition: resolve the configured [tool.crewai] flow — the same
|
|
# resolution as a bare `crewai run` — and pass --inputs straight through.
|
|
_run_flow_project(
|
|
pyproject_data=pyproject_data,
|
|
trained_agents_file=trained_agents_file,
|
|
inputs=inputs,
|
|
)
|
|
return
|
|
|
|
_reject_inputs_for_non_flow(inputs)
|
|
_run_classic_crew_project(
|
|
pyproject_data=pyproject_data,
|
|
trained_agents_file=trained_agents_file,
|
|
)
|
|
|
|
|
|
def _reject_inputs_for_non_flow(inputs: str | None) -> None:
|
|
if inputs is not None:
|
|
raise click.UsageError(
|
|
"--inputs is only supported for declarative flows and crews"
|
|
)
|
|
|
|
|
|
def _run_explicit_declarative_flow(
|
|
definition: str, inputs: str | None, trained_agents_file: str | None
|
|
) -> None:
|
|
if trained_agents_file is not None:
|
|
raise click.UsageError("--filename can only be used when running crews")
|
|
|
|
from crewai_cli.run_declarative_flow import run_declarative_flow
|
|
|
|
run_declarative_flow(definition=definition, inputs=inputs)
|
|
|
|
|
|
def _run_flow_project(
|
|
pyproject_data: dict[str, Any],
|
|
trained_agents_file: str | None,
|
|
inputs: str | None = None,
|
|
) -> None:
|
|
if trained_agents_file is not None:
|
|
raise click.UsageError("--filename can only be used when running crews")
|
|
|
|
from crewai_cli.run_declarative_flow import (
|
|
configured_project_declarative_flow,
|
|
run_declarative_flow_in_project_env,
|
|
)
|
|
|
|
if definition := configured_project_declarative_flow(pyproject_data):
|
|
run_declarative_flow_in_project_env(definition=definition, inputs=inputs)
|
|
return
|
|
|
|
# No configured declarative flow definition to resolve inputs against.
|
|
if inputs is not None:
|
|
raise click.UsageError(
|
|
"--inputs requires a declarative flow definition "
|
|
"([tool.crewai].definition) or --definition"
|
|
)
|
|
|
|
from crewai_cli.kickoff_flow import (
|
|
_load_conversational_flow_from_kickoff_script,
|
|
_run_conversational_flow_tui,
|
|
)
|
|
|
|
flow = _load_conversational_flow_from_kickoff_script()
|
|
if flow is not None:
|
|
_run_conversational_flow_tui(flow)
|
|
return
|
|
|
|
_execute_uv_script("kickoff", entity_type="flow")
|
|
|
|
|
|
def _run_classic_crew_project(
|
|
pyproject_data: dict[str, Any], trained_agents_file: str | None
|
|
) -> None:
|
|
_execute_uv_script(
|
|
"run_crew",
|
|
entity_type="crew",
|
|
trained_agents_file=trained_agents_file,
|
|
)
|
|
|
|
|
|
def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None:
|
|
crewai_version = get_crewai_version()
|
|
min_required_version = "0.71.0"
|
|
|
|
if pyproject_data.get("tool", {}).get("poetry") and (
|
|
version.parse(crewai_version) < version.parse(min_required_version)
|
|
):
|
|
click.secho(
|
|
f"You are running an older version of crewAI ({crewai_version}) that uses poetry pyproject.toml. "
|
|
f"Please run `crewai update` to update your pyproject.toml to use uv.",
|
|
fg="red",
|
|
)
|
|
|
|
|
|
def _execute_uv_script(
|
|
script_name: str,
|
|
*,
|
|
entity_type: str,
|
|
trained_agents_file: str | None = None,
|
|
) -> None:
|
|
"""Execute a project script through uv.
|
|
|
|
Args:
|
|
script_name: The project script to run.
|
|
entity_type: The user-facing entity being run.
|
|
trained_agents_file: Optional trained-agents pickle path forwarded to
|
|
the subprocess via the ``CREWAI_TRAINED_AGENTS_FILE`` env var.
|
|
"""
|
|
command = ["uv", "run", script_name]
|
|
|
|
env = build_env_with_all_tool_credentials()
|
|
if trained_agents_file:
|
|
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
|
|
|
|
try:
|
|
subprocess.run(command, capture_output=False, text=True, check=True, env=env) # noqa: S603
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
_handle_run_error(e, entity_type)
|
|
|
|
except Exception as e:
|
|
click.echo(f"An unexpected error occurred: {e}", err=True)
|
|
|
|
|
|
def _handle_run_error(error: subprocess.CalledProcessError, entity_type: str) -> None:
|
|
"""
|
|
Handle subprocess errors with appropriate messaging.
|
|
|
|
Args:
|
|
error: The subprocess error that occurred
|
|
entity_type: The type of entity that was being run
|
|
"""
|
|
click.echo(f"An error occurred while running the {entity_type}: {error}", err=True)
|
|
|
|
if error.output:
|
|
click.echo(error.output, err=True, nl=True)
|
|
|
|
pyproject_data = read_toml()
|
|
if pyproject_data.get("tool", {}).get("poetry"):
|
|
click.secho(
|
|
"It's possible that you are using an old version of crewAI that uses poetry, "
|
|
"please run `crewai update` to update your pyproject.toml to use uv.",
|
|
fg="yellow",
|
|
)
|