diff --git a/lib/cli/src/crewai_cli/input_prompt.py b/lib/cli/src/crewai_cli/input_prompt.py new file mode 100644 index 000000000..a91fdee7e --- /dev/null +++ b/lib/cli/src/crewai_cli/input_prompt.py @@ -0,0 +1,84 @@ +"""Shared interactive prompting for runtime inputs (flows and crews). + +``crewai run`` asks the user for values that were not provided up front — for a +declarative flow (derived from its state schema) and for a declarative (JSON) +crew (derived from the ``{placeholder}`` references in its agents and tasks). +Both paths go through this module so the experience is identical: the same +header, the same per-field prompt styling, and prompt chrome on stderr so +stdout carries only the run's result. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +import difflib +import json +import sys +from typing import Any + +import click + +from crewai_cli.utils import enable_prompt_line_editing, is_dmn_mode_enabled + + +def parse_inputs_json(inputs: str | None) -> dict[str, Any] | None: + """Parse a ``--inputs`` JSON object, exiting with a pointed error if invalid.""" + if inputs is None: + return None + + try: + parsed = json.loads(inputs) + except json.JSONDecodeError as exc: + click.echo(f"Invalid --inputs JSON: {exc}", err=True) + raise SystemExit(1) from exc + + if not isinstance(parsed, dict): + click.echo("Invalid --inputs JSON: expected an object.", err=True) + raise SystemExit(1) + + return parsed + + +def closest_name(key: str, candidates: Iterable[str]) -> str | None: + """Nearest candidate name to a likely typo, if one is close enough.""" + matches = difflib.get_close_matches(key, list(candidates), n=1, cutoff=0.7) + return matches[0] if matches else None + + +def is_interactive() -> bool: + """Prompt only in an interactive terminal, never in non-interactive mode.""" + return not is_dmn_mode_enabled() and sys.stdin.isatty() + + +def prompt_for_inputs( + names: list[str], + *, + title: str, + subtitle: str, + describe: Callable[[str], str | None] | None = None, + coerce: Callable[[str, str], Any] | None = None, +) -> dict[str, Any]: + """Prompt for each name and return ``{name: value}``. + + ``describe(name)`` returns an optional hint shown dim above the prompt (used + by flows to surface a field's schema description). ``coerce(name, raw)`` + converts the typed string to the stored value (used by flows to coerce to + the field's JSON-schema type); by default the raw string is kept as-is. + + Prompt chrome is written to stderr so stdout carries only the run result. + """ + enable_prompt_line_editing() + click.echo(err=True) + click.secho(f" {title}", fg="cyan", bold=True, err=True) + click.secho(f" {subtitle}", dim=True, err=True) + + collected: dict[str, Any] = {} + for name in names: + if describe is not None and (hint := describe(name)): + click.secho(f" {hint}", dim=True, err=True) + raw = click.prompt( + click.style(f" {name}", fg="cyan"), + prompt_suffix=click.style(" > ", fg="bright_white"), + ) + collected[name] = coerce(name, raw) if coerce is not None else raw + return collected diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index 82173b5d3..c4116e1b6 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -12,9 +12,14 @@ 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, - enable_prompt_line_editing, is_dmn_mode_enabled, ) from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version @@ -31,6 +36,7 @@ _INPUT_PLACEHOLDER_RE = re.compile(r"(? set[str]: 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.""" +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 []: @@ -160,32 +168,70 @@ def _missing_input_names(crew: Any, inputs: dict[str, Any]) -> list[str]: _extract_input_placeholders(getattr(task, "output_file", None)) ) - return sorted(name for name in placeholders if name not in inputs) + return placeholders -def _prompt_for_missing_inputs( - crew: Any, default_inputs: dict[str, Any] +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]: - """Ask for runtime values for placeholders that lack default inputs.""" + """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 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"), + 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 @@ -243,29 +289,14 @@ def _prepare_json_crew_for_tui(crew: Any) -> None: agent.llm.stream = True -def _runtime_inputs_without_prompt( - crew: Any, default_inputs: dict[str, Any] -) -> dict[str, Any]: - """Return runtime inputs in non-interactive mode or exit on missing values.""" - inputs = dict(default_inputs or {}) - missing = _missing_input_names(crew, inputs) - if missing: - missing_list = ", ".join(missing) - click.echo( - "Missing runtime inputs for CREWAI_DMN mode: " - f"{missing_list}. Add them to the `inputs` object in crew.json(c).", - err=True, - ) - raise SystemExit(1) - return inputs - - -def _run_json_crew_without_tui(crew_path: Path) -> Any: +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 = _runtime_inputs_without_prompt(crew, default_inputs) + 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)) @@ -275,6 +306,7 @@ def _run_json_crew_without_tui(crew_path: Path) -> Any: 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 @@ -296,13 +328,17 @@ def _run_json_crew( ) crew_path = Path(crew_path) + provided = parse_inputs_json(inputs) + if is_dmn_mode_enabled(): - return _run_json_crew_without_tui(crew_path) + 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 = _prompt_for_missing_inputs(crew, default_inputs) + runtime_inputs = _resolve_crew_inputs( + crew, default_inputs, provided, interactive=is_interactive() + ) app = crew_run_app_cls( crew_name=crew.name or "Crew", @@ -411,12 +447,18 @@ def _json_crew_run_command(project_root: Path | None = None) -> list[str]: 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() @@ -430,6 +472,8 @@ def _run_json_crew_in_project_env( 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 @@ -569,7 +613,9 @@ def run_crew( ``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 passed to a declarative Flow. + 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: @@ -582,10 +628,13 @@ def run_crew( pyproject_data = read_toml() if json_crew_definition := configured_project_json_crew(pyproject_data): - _reject_inputs_for_non_flow(inputs) + # 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 @@ -611,7 +660,9 @@ def run_crew( 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") + raise click.UsageError( + "--inputs is only supported for declarative flows and crews" + ) def _run_explicit_declarative_flow( diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index 7be34a13c..aafd649c9 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -1,21 +1,21 @@ from __future__ import annotations -import difflib import json from pathlib import Path import subprocess -import sys from typing import Any import click from crewai_core.project import ProjectDefinitionError, configured_project_definition from pydantic import ValidationError -from crewai_cli.utils import ( - build_env_with_all_tool_credentials, - enable_prompt_line_editing, - is_dmn_mode_enabled, +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 def run_declarative_flow_in_project_env( @@ -61,7 +61,7 @@ def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> N if env_file.exists(): load_dotenv(env_file, override=True) - provided = _parse_inputs(inputs) or {} + provided = parse_inputs_json(inputs) or {} flow = load_declarative_flow(definition) resolved_inputs = _resolve_flow_inputs(flow, provided) @@ -111,7 +111,7 @@ def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]: if key in properties: collected[key] = value continue - suggestion = _closest_key(key, properties) + suggestion = closest_name(key, properties) hint = f" Did you mean '{suggestion}'?" if suggestion else "" click.secho( f" Ignoring unknown input '{key}' — not in the flow's state schema.{hint}", @@ -121,7 +121,15 @@ def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]: missing = _missing_required(state_model, {**defaults, **collected}) if missing and _is_interactive(): - collected.update(_prompt_for_flow_inputs(missing, properties)) + collected.update( + prompt_for_inputs( + missing, + title="Flow inputs", + subtitle="This flow needs the following to run.", + describe=lambda name: (properties.get(name) or {}).get("description"), + coerce=lambda name, raw: _coerce_input(raw, properties.get(name) or {}), + ) + ) missing = _missing_required(state_model, {**defaults, **collected}) if missing: @@ -139,7 +147,7 @@ def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]: def _is_interactive() -> bool: """Prompt only in an interactive terminal, never in non-interactive mode.""" - return not is_dmn_mode_enabled() and sys.stdin.isatty() + return is_interactive() def _flow_state_schema(flow: Any) -> dict[str, Any] | None: @@ -190,30 +198,6 @@ def _validate_flow_inputs(state_model: Any, values: dict[str, Any]) -> None: raise SystemExit(1) from exc -def _prompt_for_flow_inputs( - missing: list[str], properties: dict[str, Any] -) -> dict[str, Any]: - """Prompt for each missing required field, showing its schema description.""" - enable_prompt_line_editing() - # Prompt chrome goes to stderr so stdout carries only the flow result. - click.echo(err=True) - click.secho(" Flow inputs", fg="cyan", bold=True, err=True) - click.secho(" This flow needs the following to run.", dim=True, err=True) - - collected: dict[str, Any] = {} - for name in missing: - spec = properties.get(name) or {} - description = spec.get("description") - if description: - click.secho(f" {description}", dim=True, err=True) - raw = click.prompt( - click.style(f" {name}", fg="cyan"), - prompt_suffix=click.style(" > ", fg="bright_white"), - ) - collected[name] = _coerce_input(raw, spec) - return collected - - def _coerce_input(raw: str, spec: dict[str, Any]) -> Any: """Best-effort coerce a prompted string to the field's JSON-schema type.""" field_type = spec.get("type") @@ -232,12 +216,6 @@ def _coerce_input(raw: str, spec: dict[str, Any]) -> Any: return raw -def _closest_key(key: str, properties: dict[str, Any]) -> str | None: - """Nearest schema field name to a likely typo, if one is close enough.""" - matches = difflib.get_close_matches(key, list(properties), n=1, cutoff=0.7) - return matches[0] if matches else None - - def plot_declarative_flow(definition: str | Path) -> None: """Plot a declarative flow from a definition path.""" try: @@ -339,23 +317,6 @@ def _has_project_file(project_root: Path | None = None) -> bool: return (root / "pyproject.toml").is_file() -def _parse_inputs(inputs: str | None) -> dict[str, Any] | None: - if inputs is None: - return None - - try: - parsed = json.loads(inputs) - except json.JSONDecodeError as exc: - click.echo(f"Invalid --inputs JSON: {exc}", err=True) - raise SystemExit(1) from exc - - if not isinstance(parsed, dict): - click.echo("Invalid --inputs JSON: expected an object.", err=True) - raise SystemExit(1) - - return parsed - - def _format_result(result: Any) -> str: raw_result = getattr(result, "raw", result) if isinstance(raw_result, str): diff --git a/lib/cli/tests/test_input_prompt.py b/lib/cli/tests/test_input_prompt.py new file mode 100644 index 000000000..e43deb417 --- /dev/null +++ b/lib/cli/tests/test_input_prompt.py @@ -0,0 +1,83 @@ +"""Tests for the shared runtime-input prompting used by flows and crews.""" + +from __future__ import annotations + +import pytest + +import crewai_cli.input_prompt as input_prompt_module +from crewai_cli.input_prompt import ( + closest_name, + parse_inputs_json, + prompt_for_inputs, +) + + +def test_parse_inputs_json_returns_none_for_none(): + assert parse_inputs_json(None) is None + + +def test_parse_inputs_json_parses_object(): + assert parse_inputs_json('{"topic": "AI"}') == {"topic": "AI"} + + +def test_parse_inputs_json_rejects_invalid_json(capsys): + with pytest.raises(SystemExit) as exc_info: + parse_inputs_json("not json") + + assert exc_info.value.code == 1 + assert "Invalid --inputs JSON" in capsys.readouterr().err + + +def test_parse_inputs_json_rejects_non_object(capsys): + with pytest.raises(SystemExit) as exc_info: + parse_inputs_json("[1, 2, 3]") + + assert exc_info.value.code == 1 + assert "expected an object" in capsys.readouterr().err + + +def test_closest_name_suggests_near_miss(): + assert closest_name("prospect_emai", ["prospect_email", "topic"]) == "prospect_email" + + +def test_closest_name_returns_none_when_nothing_close(): + assert closest_name("zzzzz", ["prospect_email", "topic"]) is None + + +def test_prompt_for_inputs_uses_describe_and_coerce(monkeypatch, capsys): + seen: list[str] = [] + + def fake_prompt(text: str, **kwargs: object) -> str: + seen.append(text) + return "42" + + monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt) + + result = prompt_for_inputs( + ["count"], + title="Flow inputs", + subtitle="This flow needs the following to run.", + describe=lambda name: f"How many {name}?", + coerce=lambda name, raw: int(raw), + ) + + captured = capsys.readouterr() + assert result == {"count": 42} + assert any("count" in text for text in seen) + # Header, subtitle, and description hint all render on stderr. + assert "Flow inputs" in captured.err + assert "How many count?" in captured.err + + +def test_prompt_for_inputs_keeps_raw_string_without_coerce(monkeypatch): + monkeypatch.setattr( + input_prompt_module.click, "prompt", lambda text, **kwargs: "AI" + ) + + result = prompt_for_inputs( + ["topic"], + title="Crew inputs", + subtitle="This crew needs the following to run.", + ) + + assert result == {"topic": "AI"} diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index 3c7a4ac2e..777e264a4 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -9,6 +9,7 @@ import click import pytest from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV +import crewai_cli.input_prompt as input_prompt_module import crewai_cli.run_crew as run_crew_module @@ -44,9 +45,12 @@ def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch): ) called: dict = {} - def fake_run_json_crew_in_project_env(trained_agents_file=None, crew_path=None): + def fake_run_json_crew_in_project_env( + trained_agents_file=None, crew_path=None, inputs=None + ): called["trained_agents_file"] = trained_agents_file called["crew_path"] = crew_path + called["inputs"] = inputs monkeypatch.setattr( run_crew_module, @@ -59,6 +63,7 @@ def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch): assert called == { "trained_agents_file": "some.pkl", "crew_path": Path("crew.jsonc"), + "inputs": None, } @@ -305,9 +310,10 @@ def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path) monkeypatch.chdir(tmp_path) called: dict = {} - def fake_run_json_crew(trained_agents_file=None, crew_path=None): + def fake_run_json_crew(trained_agents_file=None, crew_path=None, inputs=None): called["trained_agents_file"] = trained_agents_file called["crew_path"] = crew_path + called["inputs"] = inputs return "result" monkeypatch.setattr(run_crew_module, "_run_json_crew", fake_run_json_crew) @@ -318,7 +324,11 @@ def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path) ) == "result" ) - assert called == {"trained_agents_file": "trained.pkl", "crew_path": None} + assert called == { + "trained_agents_file": "trained.pkl", + "crew_path": None, + "inputs": None, + } def test_json_project_env_run_failure_exits_nonzero(monkeypatch, tmp_path: Path): @@ -535,7 +545,9 @@ def _patch_tui_run(monkeypatch, status: str): lambda _path: (FakeApp, crew, {}, [], []), ) monkeypatch.setattr( - run_crew_module, "_prompt_for_missing_inputs", lambda _crew, inputs: inputs + run_crew_module, + "_resolve_crew_inputs", + lambda _crew, default_inputs, _provided, *, interactive: default_inputs, ) monkeypatch.setattr(run_crew_module, "_print_post_tui_summary", lambda _app: None) @@ -636,7 +648,168 @@ def test_run_json_crew_dmn_mode_exits_on_missing_inputs( captured = capsys.readouterr() assert exc_info.value.code == 1 - assert "Missing runtime inputs for CREWAI_DMN mode: topic" in captured.err + assert "Missing required input 'topic'" in captured.err + + +# ── Declarative-crew inputs: merge --inputs, warn, prompt (flow parity) ── + + +def _crew_with_placeholders(*names: str) -> object: + """A minimal crew whose agent goal references each ``{name}`` placeholder.""" + from types import SimpleNamespace + + goal = " ".join(f"{{{name}}}" for name in names) + return SimpleNamespace( + agents=[SimpleNamespace(role="Researcher", goal=goal, backstory="")], + tasks=[], + ) + + +def test_resolve_crew_inputs_merges_inputs_over_defaults(): + crew = _crew_with_placeholders("topic") + + resolved = run_crew_module._resolve_crew_inputs( + crew, {"topic": "AI"}, {"topic": "ML"}, interactive=False + ) + + assert resolved == {"topic": "ML"} + + +def test_resolve_crew_inputs_warns_and_keeps_unknown_input(capsys): + # The placeholder scan is heuristic, so an unreferenced key is flagged but + # kept (never silently dropped like a flow's schema would). + crew = _crew_with_placeholders("topic") + + resolved = run_crew_module._resolve_crew_inputs( + crew, {}, {"topic": "AI", "topi": "typo"}, interactive=False + ) + + captured = capsys.readouterr() + assert resolved == {"topic": "AI", "topi": "typo"} + assert "isn't referenced by any {placeholder}" in captured.err + assert "Did you mean 'topic'?" in captured.err + + +def test_resolve_crew_inputs_prompts_when_interactive(monkeypatch): + crew = _crew_with_placeholders("topic") + prompted: list[str] = [] + + def fake_prompt(text: str, **kwargs: object) -> str: + prompted.append(text) + return "AI" + + monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt) + + resolved = run_crew_module._resolve_crew_inputs( + crew, {}, None, interactive=True + ) + + assert resolved == {"topic": "AI"} + assert any("topic" in text for text in prompted) + + +def test_resolve_crew_inputs_errors_when_missing_non_interactive(capsys): + crew = _crew_with_placeholders("topic") + + with pytest.raises(SystemExit) as exc_info: + run_crew_module._resolve_crew_inputs(crew, {}, None, interactive=False) + + assert exc_info.value.code == 1 + assert "Missing required input 'topic'" in capsys.readouterr().err + + +def test_run_json_crew_accepts_inputs_argument(monkeypatch, tmp_path: Path, capsys): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CREWAI_DMN", "True") + crew_path = tmp_path / "crew.jsonc" + crew_path.write_text("{}") + kickoff_calls: list[dict] = [] + + class FakeCrew: + name = "Demo" + agents = [_crew_with_placeholders("topic").agents[0]] + tasks: list = [] + + def kickoff(self, inputs): + kickoff_calls.append(inputs) + return "ok" + + monkeypatch.setattr( + run_crew_module, "configured_project_json_crew", lambda: crew_path + ) + monkeypatch.setattr( + run_crew_module, "_load_json_crew", lambda _path: (FakeCrew(), {}) + ) + + assert run_crew_module._run_json_crew(inputs='{"topic":"AI"}') == "ok" + assert kickoff_calls == [{"topic": "AI"}] + + +def test_run_json_crew_in_project_env_forwards_inputs(monkeypatch, tmp_path: Path): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n") + monkeypatch.setattr( + run_crew_module, "_install_json_crew_dependencies_if_needed", lambda: None + ) + monkeypatch.setattr( + run_crew_module, "build_env_with_all_tool_credentials", lambda: {} + ) + captured_kwargs: list[dict] = [] + monkeypatch.setattr( + run_crew_module.subprocess, + "run", + lambda command, **kwargs: captured_kwargs.append(kwargs), + ) + + run_crew_module._run_json_crew_in_project_env( + crew_path=tmp_path / "crew.jsonc", inputs='{"topic":"AI"}' + ) + + env = captured_kwargs[0]["env"] + assert env[run_crew_module._CREWAI_JSON_CREW_INPUTS_ENV] == '{"topic":"AI"}' + + +def test_run_json_crew_in_project_env_rejects_invalid_inputs_json( + monkeypatch, tmp_path: Path, capsys +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n") + monkeypatch.setattr( + run_crew_module.subprocess, + "run", + lambda *a, **k: pytest.fail("subprocess must not run on invalid --inputs"), + ) + + with pytest.raises(SystemExit) as exc_info: + run_crew_module._run_json_crew_in_project_env( + crew_path=tmp_path / "crew.jsonc", inputs="not json" + ) + + assert exc_info.value.code == 1 + assert "Invalid --inputs JSON" in capsys.readouterr().err + + +def test_run_crew_forwards_inputs_to_json_crew(monkeypatch): + monkeypatch.setattr(run_crew_module, "read_toml", lambda *a, **k: {}) + monkeypatch.setattr( + run_crew_module, + "configured_project_json_crew", + lambda *a, **k: Path("crew.jsonc"), + ) + called: dict = {} + monkeypatch.setattr( + run_crew_module, + "_run_json_crew_in_project_env", + lambda **kw: called.update(kw), + ) + + run_crew_module.run_crew(inputs='{"topic":"AI"}') + + assert called == { + "trained_agents_file": None, + "crew_path": Path("crew.jsonc"), + "inputs": '{"topic":"AI"}', + } def test_configured_project_json_crew_defers_to_declared_flow_type( @@ -682,9 +855,9 @@ def test_configured_project_json_crew_ignores_missing_pyproject( assert run_crew_module.configured_project_json_crew() is None -def test_run_crew_inputs_without_definition_rejected_for_non_flow(monkeypatch): - # --inputs is flow-only; in a non-flow project it now errors clearly instead - # of the old "--inputs requires --definition". +def test_run_crew_inputs_rejected_for_classic_crew(monkeypatch): + # --inputs works for declarative flows and declarative (JSON) crews, but a + # classic crew takes its inputs from main.py, so it errors clearly. monkeypatch.setattr(run_crew_module, "read_toml", lambda *a, **k: {}) monkeypatch.setattr( run_crew_module, "configured_project_json_crew", lambda *a, **k: None @@ -697,7 +870,10 @@ def test_run_crew_inputs_without_definition_rejected_for_non_flow(monkeypatch): with pytest.raises(click.UsageError) as exc_info: run_crew_module.run_crew(inputs='{"topic":"AI"}') - assert "--inputs is only supported for declarative flows" in exc_info.value.message + assert ( + "--inputs is only supported for declarative flows and crews" + in exc_info.value.message + ) def test_run_crew_inputs_without_definition_resolves_configured_flow(monkeypatch): diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py index df6c0af00..4951615c5 100644 --- a/lib/cli/tests/test_run_declarative_flow.py +++ b/lib/cli/tests/test_run_declarative_flow.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +import crewai_cli.input_prompt as input_prompt_module import crewai_cli.run_declarative_flow as run_declarative_flow_module @@ -289,7 +290,7 @@ def test_prompts_for_missing_required_when_interactive( prompted.append(text) return "typed@example.com" - monkeypatch.setattr(run_declarative_flow_module.click, "prompt", fake_prompt) + monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt) run_declarative_flow_module.run_declarative_flow(str(path))