diff --git a/lib/cli/src/crewai_cli/cli.py b/lib/cli/src/crewai_cli/cli.py index 30eccdc61..28cec9d97 100644 --- a/lib/cli/src/crewai_cli/cli.py +++ b/lib/cli/src/crewai_cli/cli.py @@ -526,8 +526,8 @@ def run( inputs: str | None, ) -> None: """Run the Crew or Flow.""" - if inputs is not None and definition is None: - raise click.UsageError("--inputs requires --definition") + # --inputs no longer requires --definition: with no override it resolves the + # configured [tool.crewai] flow, same as a bare `crewai run`. if trained_agents_file is not None and definition is not None: raise click.UsageError("--filename can only be used when running crews") 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 8fde69ee8..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,11 +613,11 @@ 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. """ - if inputs is not None and definition is None: - raise click.UsageError("--inputs requires --definition") - + # --definition is a pure override: run that flow directly. if definition is not None: _run_explicit_declarative_flow( definition=definition, @@ -584,9 +628,13 @@ def run_crew( pyproject_data = read_toml() 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 @@ -594,18 +642,29 @@ def run_crew( 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: @@ -618,7 +677,9 @@ def _run_explicit_declarative_flow( def _run_flow_project( - pyproject_data: dict[str, Any], trained_agents_file: str | None + 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") @@ -629,9 +690,16 @@ def _run_flow_project( ) if definition := configured_project_declarative_flow(pyproject_data): - run_declarative_flow_in_project_env(definition=definition) + 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, diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index 49af0f079..6061d369e 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -9,6 +9,12 @@ import click from crewai_core.project import ProjectDefinitionError, configured_project_definition from pydantic import ValidationError +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 @@ -20,10 +26,13 @@ def run_declarative_flow_in_project_env( run_declarative_flow(definition=definition, inputs=inputs) return + # Re-run inside the project env (so the flow loads with the project's deps). + # The configured definition is re-resolved there; forward --inputs so the + # in-env run kicks off with the same values instead of losing them. + command = ["uv", "run", "crewai", "run"] if inputs is not None: - raise click.UsageError("--inputs is only supported with --definition") - - _execute_declarative_flow_command(["uv", "run", "crewai", "run"]) + command += ["--inputs", inputs] + _execute_declarative_flow_command(command) def plot_declarative_flow_in_project_env(definition: str | Path) -> None: @@ -36,12 +45,29 @@ def plot_declarative_flow_in_project_env(definition: str | Path) -> None: def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None: - """Run a declarative flow from a definition path.""" - parsed_inputs = _parse_inputs(inputs) + """Run a declarative flow from a definition path. + + Inputs come from one place: the flow's own state schema. Any ``--inputs`` + JSON is layered on top as an override, missing required fields are prompted + for interactively, and everything is validated against the schema before + kickoff — so a bare ``crewai run`` on a configured flow just works. + """ + # Load the project's .env before kickoff, mirroring the JSON-crew path + # (run_crew._run_json_crew) so flow projects pick up API keys/config the + # same way regardless of where crewai is installed. + from dotenv import load_dotenv + + env_file = Path.cwd() / ".env" + if env_file.exists(): + load_dotenv(env_file, override=True) + + provided = parse_inputs_json(inputs) or {} + + flow = load_declarative_flow(definition) + resolved_inputs = _resolve_flow_inputs(flow, provided) try: - flow = load_declarative_flow(definition) - result = flow.kickoff(inputs=parsed_inputs) + result = flow.kickoff(inputs=resolved_inputs or None) except Exception as exc: click.echo( f"An error occurred while running the declarative flow: {exc}", err=True @@ -51,6 +77,152 @@ def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> N click.echo(_format_result(result)) +def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]: + """Resolve kickoff inputs from the flow's state schema. + + Warns on unknown keys, prompts for missing required fields (unless + non-interactive), and validates types before kickoff. Exits with a pointed + message when a required input is still missing or an input is invalid. + """ + schema = _flow_state_schema(flow) + if schema is None: + # dict / unschematized state — nothing to derive; pass inputs through. + return dict(provided) + + properties = { + name: spec + for name, spec in (schema.get("properties") or {}).items() + if name != "id" + } + state_model = type(flow.state) + defaults = _flow_state_defaults(flow) + + # ``id`` signals a persistence restore: kickoff hydrates the full state from + # storage, so required fields may come from the restored state rather than + # --inputs. We still filter the rest of the payload below, but skip the + # required-field prompt and pre-kickoff validation, which would otherwise + # fail on fields the resume will supply. + restoring = "id" in provided + + # Unknown keys are almost always typos — warn and drop them (they'd fail + # structured-state validation at kickoff anyway). ``id`` is a reserved + # kickoff key rather than a state field, so forward it untouched. + collected: dict[str, Any] = {} + for key, value in provided.items(): + if key == "id": + collected["id"] = value + continue + if key in properties: + collected[key] = value + continue + 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}", + fg="yellow", + err=True, + ) + + if restoring: + return collected + + missing = _missing_required(state_model, {**defaults, **collected}) + if missing and _is_interactive(): + 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: + for name in missing: + description = (properties.get(name) or {}).get("description") + suffix = f" — {description}" if description else "" + click.secho( + f" Missing required input '{name}'{suffix}", fg="red", err=True + ) + raise SystemExit(1) + + _validate_flow_inputs(state_model, {**defaults, **collected}) + return collected + + +def _is_interactive() -> bool: + """Prompt only in an interactive terminal, never in non-interactive mode.""" + return is_interactive() + + +def _flow_state_schema(flow: Any) -> dict[str, Any] | None: + """Return the flow's state JSON schema, or ``None`` for dict/plain state.""" + state = getattr(flow, "state", None) + if state is None or isinstance(state, dict): + return None + model_json_schema = getattr(type(state), "model_json_schema", None) + if not callable(model_json_schema): + return None + try: + schema = model_json_schema() + except Exception: + return None + return schema if isinstance(schema, dict) else None + + +def _flow_state_defaults(flow: Any) -> dict[str, Any]: + """Declared state defaults (``state.default``) from the flow definition.""" + state_definition = getattr(getattr(flow, "_definition", None), "state", None) + default = getattr(state_definition, "default", None) + return dict(default) if isinstance(default, dict) else {} + + +def _missing_required(state_model: Any, values: dict[str, Any]) -> list[str]: + """Required state fields not satisfied by ``values`` (defaults + inputs).""" + try: + state_model.model_validate(values) + except ValidationError as exc: + return [ + str(error["loc"][0]) + for error in exc.errors() + if error.get("type") == "missing" and error.get("loc") + ] + return [] + + +def _validate_flow_inputs(state_model: Any, values: dict[str, Any]) -> None: + """Validate inputs against the state schema; exit with pointed type errors.""" + try: + state_model.model_validate(values) + except ValidationError as exc: + for error in exc.errors(): + location = ".".join(str(part) for part in error.get("loc", ())) + click.secho( + f" Invalid input '{location}': {error.get('msg')}", fg="red", err=True + ) + raise SystemExit(1) from exc + + +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") + if field_type == "integer": + try: + return int(raw) + except ValueError: + return raw + if field_type == "number": + try: + return float(raw) + except ValueError: + return raw + if field_type == "boolean": + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + return raw + + def plot_declarative_flow(definition: str | Path) -> None: """Plot a declarative flow from a definition path.""" try: @@ -152,23 +324,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_cli.py b/lib/cli/tests/test_cli.py index acdeee7ff..535daf7f2 100644 --- a/lib/cli/tests/test_cli.py +++ b/lib/cli/tests/test_cli.py @@ -151,12 +151,14 @@ def test_run_with_definition_uses_project_runner(run_crew, runner): @mock.patch("crewai_cli.cli.run_crew") -def test_run_rejects_inputs_without_definition(run_crew, runner): +def test_run_inputs_without_definition_calls_run_crew(run_crew, runner): + # --inputs no longer requires --definition; the resolution happens in run_crew. result = runner.invoke(run, ["--inputs", '{"topic":"AI"}']) - assert result.exit_code == 2 - assert "Error: --inputs requires --definition" in result.output - run_crew.assert_not_called() + assert result.exit_code == 0 + run_crew.assert_called_once_with( + trained_agents_file=None, definition=None, inputs='{"topic":"AI"}' + ) @mock.patch("crewai_cli.cli.run_crew") diff --git a/lib/cli/tests/test_input_prompt.py b/lib/cli/tests/test_input_prompt.py new file mode 100644 index 000000000..22bb334a9 --- /dev/null +++ b/lib/cli/tests/test_input_prompt.py @@ -0,0 +1,82 @@ +"""Tests for the shared runtime-input prompting used by flows and crews.""" + +from __future__ import annotations + +import pytest + +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("crewai_cli.input_prompt.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( + "crewai_cli.input_prompt.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 a1b4c73bc..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,11 +855,51 @@ def test_configured_project_json_crew_ignores_missing_pyproject( assert run_crew_module.configured_project_json_crew() is None -def test_run_crew_rejects_inputs_without_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 + ) + monkeypatch.setattr( + run_crew_module, "_warn_if_old_poetry_project", lambda *a, **k: None + ) + monkeypatch.setattr(run_crew_module, "get_crewai_project_type", lambda *a, **k: "crew") + with pytest.raises(click.UsageError) as exc_info: run_crew_module.run_crew(inputs='{"topic":"AI"}') - assert "--inputs requires --definition" 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): + # --inputs with no --definition resolves the configured [tool.crewai] flow, + # exactly like a bare `crewai run`, and forwards the inputs. + import crewai_cli.run_declarative_flow as rdf + + calls: dict[str, object] = {} + monkeypatch.setattr(run_crew_module, "read_toml", lambda *a, **k: {}) + monkeypatch.setattr( + run_crew_module, "configured_project_json_crew", lambda *a, **k: None + ) + monkeypatch.setattr( + run_crew_module, "_warn_if_old_poetry_project", lambda *a, **k: None + ) + monkeypatch.setattr(run_crew_module, "get_crewai_project_type", lambda *a, **k: "flow") + monkeypatch.setattr( + rdf, "configured_project_declarative_flow", lambda *a, **k: Path("flow.yaml") + ) + monkeypatch.setattr( + rdf, "run_declarative_flow_in_project_env", lambda **kw: calls.update(kw) + ) + + run_crew_module.run_crew(inputs='{"topic":"AI"}') + + assert calls == {"definition": Path("flow.yaml"), "inputs": '{"topic":"AI"}'} def test_run_crew_rejects_filename_with_explicit_definition(): diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py index 8d5435c8f..abc49396b 100644 --- a/lib/cli/tests/test_run_declarative_flow.py +++ b/lib/cli/tests/test_run_declarative_flow.py @@ -1,9 +1,11 @@ from __future__ import annotations +import os 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 @@ -146,3 +148,255 @@ def test_run_declarative_flow_in_process_inside_uv( ) assert capsys.readouterr().out == "AI\n" + + +def test_run_declarative_flow_in_project_env_forwards_inputs( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + subprocess_calls = [] + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False) + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n") + monkeypatch.setattr( + run_declarative_flow_module, + "build_env_with_all_tool_credentials", + lambda: {}, + ) + monkeypatch.setattr( + run_declarative_flow_module.subprocess, + "run", + lambda command, **kwargs: subprocess_calls.append(command), + ) + + run_declarative_flow_module.run_declarative_flow_in_project_env( + "flow.yaml", '{"topic":"AI"}' + ) + + # --inputs is forwarded to the in-env run instead of being rejected. + assert subprocess_calls == [ + ["uv", "run", "crewai", "run", "--inputs", '{"topic":"AI"}'] + ] + + +# ── Schema-driven inputs: prompt, validate, override ──────────────── + +REQUIRED_FLOW_YAML = """\ +schema: crewai.flow/v1 +name: RequiredInputFlow +config: + suppress_flow_events: true +state: + type: json_schema + json_schema: + type: object + properties: + prospect_email: + type: string + description: Email address of the prospect to research + required: [prospect_email] +methods: + begin: + start: true + do: + call: expression + expr: state.prospect_email +""" + +DEFAULTS_FLOW_YAML = """\ +schema: crewai.flow/v1 +name: DefaultsFlow +config: + suppress_flow_events: true +state: + type: json_schema + json_schema: + type: object + properties: + topic: {type: string} + audience: {type: string} + required: [topic, audience] + default: + topic: AI +methods: + begin: + start: true + do: + call: expression + expr: state.audience +""" + +TYPED_FLOW_YAML = """\ +schema: crewai.flow/v1 +name: TypedFlow +config: + suppress_flow_events: true +state: + type: json_schema + json_schema: + type: object + properties: + count: {type: integer} + required: [count] +methods: + begin: + start: true + do: + call: expression + expr: state.count +""" + + +def _write(tmp_path: Path, contents: str) -> Path: + path = tmp_path / "flow.yaml" + path.write_text(contents, encoding="utf-8") + return path + + +def test_inputs_flag_satisfies_required_field( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = _write(tmp_path, REQUIRED_FLOW_YAML) + + run_declarative_flow_module.run_declarative_flow( + str(path), '{"prospect_email":"a@b.com"}' + ) + + assert capsys.readouterr().out == "a@b.com\n" + + +def test_missing_required_reports_pointed_error( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: False) + path = _write(tmp_path, REQUIRED_FLOW_YAML) + + with pytest.raises(SystemExit): + run_declarative_flow_module.run_declarative_flow(str(path)) + + assert ( + "Missing required input 'prospect_email' — " + "Email address of the prospect to research" in capsys.readouterr().err + ) + + +def test_prompts_for_missing_required_when_interactive( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + path = _write(tmp_path, REQUIRED_FLOW_YAML) + monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: True) + prompted: list[str] = [] + + def fake_prompt(text: str, **kwargs: object) -> str: + prompted.append(text) + return "typed@example.com" + + monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt) + + run_declarative_flow_module.run_declarative_flow(str(path)) + + assert capsys.readouterr().out == "typed@example.com\n" + assert any("prospect_email" in text for text in prompted) + + +def test_defaults_satisfy_required_and_are_not_prompted( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: False) + path = _write(tmp_path, DEFAULTS_FLOW_YAML) + + with pytest.raises(SystemExit): + run_declarative_flow_module.run_declarative_flow(str(path)) + + err = capsys.readouterr().err + # topic has a state default -> satisfied; only audience is missing. + assert "Missing required input 'audience'" in err + assert "'topic'" not in err + + +def test_warns_on_unknown_input_with_suggestion( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = _write(tmp_path, REQUIRED_FLOW_YAML) + + run_declarative_flow_module.run_declarative_flow( + str(path), '{"prospect_email":"a@b.com","prospect_emai":"typo"}' + ) + + captured = capsys.readouterr() + assert captured.out == "a@b.com\n" + assert "Ignoring unknown input 'prospect_emai'" in captured.err + assert "Did you mean 'prospect_email'?" in captured.err + + +def test_validates_input_types_before_kickoff( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = _write(tmp_path, TYPED_FLOW_YAML) + + with pytest.raises(SystemExit): + run_declarative_flow_module.run_declarative_flow(str(path), '{"count":"nope"}') + + assert "Invalid input 'count'" in capsys.readouterr().err + + +def test_reserved_id_input_is_forwarded_not_dropped( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # `id` is a reserved kickoff key (persistence restore); it must pass through + # instead of being flagged as an unknown key and dropped. + path = _write(tmp_path, REQUIRED_FLOW_YAML) + + run_declarative_flow_module.run_declarative_flow( + str(path), '{"id":"run-123","prospect_email":"a@b.com"}' + ) + + captured = capsys.readouterr() + assert captured.out == "a@b.com\n" + assert "Ignoring unknown input 'id'" not in captured.err + + +def test_run_declarative_flow_loads_project_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Flow projects must pick up the project's .env, like crew projects do, + # overriding any pre-existing value. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("DECL_FLOW_ENV_PROBE", "old") + (tmp_path / ".env").write_text("DECL_FLOW_ENV_PROBE=from_dotenv\n", encoding="utf-8") + path = _write(tmp_path, REQUIRED_FLOW_YAML) + + run_declarative_flow_module.run_declarative_flow( + str(path), '{"prospect_email":"a@b.com"}' + ) + + assert os.environ["DECL_FLOW_ENV_PROBE"] == "from_dotenv" + + +def test_id_only_input_skips_required_validation(tmp_path: Path) -> None: + # Resume via `crewai run --inputs '{"id":"..."}'` must not be blocked by the + # required-field check: kickoff hydrates required state from persistence. + path = _write(tmp_path, REQUIRED_FLOW_YAML) + flow = run_declarative_flow_module.load_declarative_flow(str(path)) + + resolved = run_declarative_flow_module._resolve_flow_inputs(flow, {"id": "run-123"}) + + assert resolved == {"id": "run-123"} + + +def test_id_restore_still_drops_unknown_keys( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # A persistence restore (`id` present) still filters typo keys so they don't + # reach kickoff and trip strict (extra="forbid") state models — it only + # skips the required-field prompt/validation, not the unknown-key warning. + path = _write(tmp_path, REQUIRED_FLOW_YAML) + flow = run_declarative_flow_module.load_declarative_flow(str(path)) + + resolved = run_declarative_flow_module._resolve_flow_inputs( + flow, {"id": "run-123", "prospect_emai": "typo"} + ) + + captured = capsys.readouterr() + assert resolved == {"id": "run-123"} # id kept, typo dropped + assert "Ignoring unknown input 'prospect_emai'" in captured.err + assert "Ignoring unknown input 'id'" not in captured.err diff --git a/lib/crewai/tests/cli/test_run_crew.py b/lib/crewai/tests/cli/test_run_crew.py index c58772083..880c00930 100644 --- a/lib/crewai/tests/cli/test_run_crew.py +++ b/lib/crewai/tests/cli/test_run_crew.py @@ -14,7 +14,7 @@ from crewai_cli.run_crew import ( _execute_uv_script, _load_json_crew_for_tui, _missing_input_names, - _prompt_for_missing_inputs, + _resolve_crew_inputs, ) @@ -105,7 +105,7 @@ def test_missing_input_names_scans_agent_and_task_placeholders() -> None: ] -def test_prompt_for_missing_inputs_merges_runtime_values(monkeypatch) -> None: +def test_resolve_crew_inputs_merges_runtime_values(monkeypatch) -> None: crew = SimpleNamespace( agents=[SimpleNamespace(role="Researcher", goal="Cover {topic}", backstory="")], tasks=[ @@ -123,9 +123,10 @@ def test_prompt_for_missing_inputs_merges_runtime_values(monkeypatch) -> None: return values["audience"] raise AssertionError(f"Unexpected prompt: {label}") - monkeypatch.setattr("crewai_cli.run_crew.click.prompt", prompt) + # Prompting now lives in the shared crewai_cli.input_prompt helper. + monkeypatch.setattr("crewai_cli.input_prompt.click.prompt", prompt) - assert _prompt_for_missing_inputs(crew, {"topic": "AI"}) == { + assert _resolve_crew_inputs(crew, {"topic": "AI"}, None, interactive=True) == { "topic": "AI", "audience": "developers", }