feat(cli): unify runtime-input prompting across declarative flows and crews

Declarative (JSON) crews now resolve inputs the same way declarative flows
do, via a shared crewai_cli.input_prompt module (prompt_for_inputs,
parse_inputs_json, closest_name, is_interactive):

- accept --inputs (previously rejected for crews), forwarded to the crew
  subprocess via CREWAI_JSON_CREW_INPUTS and validated before spinning up uv
- layer --inputs over the crew's declared `inputs` defaults
- prompt for missing {placeholder}s with the same UX as flows, and error
  cleanly with a pointed per-name message when non-interactive
- warn on unknown keys with a "did you mean" suggestion

Unlike flows — whose state schema is authoritative, so unknown keys are
dropped — the crew placeholder scan is heuristic (agent/task text fields
only), so unrecognized keys are warned about but kept, to avoid discarding a
value a field the scan doesn't cover may rely on.

--inputs remains rejected for classic (Python/YAML) crews, which take their
inputs from main.py. run_declarative_flow's private input helpers move to the
shared module with no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
This commit is contained in:
Joao Moura
2026-07-07 12:03:54 -07:00
parent 792ca8ec75
commit 96546e37c3
6 changed files with 470 additions and 114 deletions

View File

@@ -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"}

View File

@@ -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):

View File

@@ -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))