From ebbc0998ef726721b76a1710ab6393e4f4befa73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Tue, 16 Jun 2026 19:48:31 -0300 Subject: [PATCH] Implement DMN mode support in crew creation and execution (#6194) * Implement DMN mode support in crew creation and execution - Added `is_dmn_mode_enabled` utility to check for enterprise non-interactive mode based on the `CREWAI_DMN` environment variable. - Updated `create` function in `cli.py` to enforce required parameters when DMN mode is active, raising appropriate usage errors. - Enhanced `create_crew` and `create_json_crew` functions to skip provider prompts and handle folder existence checks in DMN mode. - Introduced non-interactive defaults for agent and task creation in DMN mode, ensuring seamless project setup without user input. - Modified `run_crew` to bypass TUI and handle runtime inputs directly when in DMN mode, improving execution flow for JSON-defined crews. - Added tests to validate DMN mode behavior, ensuring correct handling of required inputs and non-interactive defaults. * Implement DMN mode support in crew creation and execution - Introduced `is_dmn_mode_enabled()` utility to check for non-interactive mode based on the `CREWAI_DMN` environment variable. - Updated `create` function to enforce required parameters when DMN mode is active, raising appropriate usage errors. - Modified `create_crew` and `create_json_crew` functions to skip provider prompts and utilize non-interactive defaults in DMN mode. - Enhanced `run_crew` to bypass TUI and handle runtime inputs directly in DMN mode, ensuring smooth execution without user interaction. - Added tests to validate DMN mode behavior, including requirements for type and name, and ensuring proper handling of existing folders and missing inputs. --- lib/cli/src/crewai_cli/cli.py | 11 +++ lib/cli/src/crewai_cli/create_crew.py | 11 ++- lib/cli/src/crewai_cli/create_json_crew.py | 64 ++++++++++++++++-- lib/cli/src/crewai_cli/run_crew.py | 33 +++++++++ lib/cli/src/crewai_cli/utils.py | 9 +++ lib/cli/tests/test_cli.py | 23 +++++++ lib/cli/tests/test_create_crew.py | 31 +++++++++ lib/cli/tests/test_run_crew.py | 79 ++++++++++++++++++++++ lib/cli/tests/test_utils.py | 17 +++++ 9 files changed, 270 insertions(+), 8 deletions(-) diff --git a/lib/cli/src/crewai_cli/cli.py b/lib/cli/src/crewai_cli/cli.py index 4257771dd..b153885f3 100644 --- a/lib/cli/src/crewai_cli/cli.py +++ b/lib/cli/src/crewai_cli/cli.py @@ -17,6 +17,7 @@ from crewai_cli.user_data import ( from crewai_cli.utils import ( build_env_with_all_tool_credentials, enable_prompt_line_editing, + is_dmn_mode_enabled, read_toml, ) @@ -162,7 +163,13 @@ def create( classic: bool = False, ) -> None: """Create a new crew, or flow.""" + dmn_mode = is_dmn_mode_enabled() if not type: + if dmn_mode: + raise click.UsageError( + "TYPE is required when CREWAI_DMN is set. " + "Use `crewai create crew ` or `crewai create flow `." + ) from crewai_cli.tui_picker import pick options = [ @@ -177,11 +184,15 @@ def create( raise SystemExit(0) click.echo() if not name: + if dmn_mode: + raise click.UsageError("NAME is required when CREWAI_DMN is set.") enable_prompt_line_editing() name = click.prompt( click.style(f" Name of your {type}", fg="cyan", bold=True), prompt_suffix=click.style(" › ", fg="bright_white"), # noqa: RUF001 ) + if dmn_mode: + skip_provider = True if type == "crew": if classic: from crewai_cli.create_crew import create_crew diff --git a/lib/cli/src/crewai_cli/create_crew.py b/lib/cli/src/crewai_cli/create_crew.py index cecceaa99..d7c33bd62 100644 --- a/lib/cli/src/crewai_cli/create_crew.py +++ b/lib/cli/src/crewai_cli/create_crew.py @@ -11,7 +11,12 @@ from crewai_cli.provider import ( select_model, select_provider, ) -from crewai_cli.utils import copy_template, load_env_vars, write_env_file +from crewai_cli.utils import ( + copy_template, + is_dmn_mode_enabled, + load_env_vars, + write_env_file, +) def get_reserved_script_names() -> set[str]: @@ -120,6 +125,8 @@ def create_folder_structure( folder_path = Path(folder_name) if folder_path.exists(): + if is_dmn_mode_enabled(): + raise click.ClickException(f"Folder {folder_name} already exists.") if not click.confirm( f"Folder {folder_name} already exists. Do you want to override it?" ): @@ -201,6 +208,8 @@ def create_crew( ) -> None: folder_path, folder_name, class_name = create_folder_structure(name, parent_folder) env_vars = load_env_vars(folder_path) + if is_dmn_mode_enabled(): + skip_provider = True if not skip_provider: if not provider: provider_models = get_provider_data() diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py index 353127491..cf84a7f1b 100644 --- a/lib/cli/src/crewai_cli/create_json_crew.py +++ b/lib/cli/src/crewai_cli/create_json_crew.py @@ -14,7 +14,12 @@ from rich.text import Text from crewai_cli.constants import ENV_VARS from crewai_cli.tui_picker import pick_many, pick_one -from crewai_cli.utils import enable_prompt_line_editing, load_env_vars, write_env_file +from crewai_cli.utils import ( + enable_prompt_line_editing, + is_dmn_mode_enabled, + load_env_vars, + write_env_file, +) # ── Provider / model data ─────────────────────────────────────── @@ -641,6 +646,43 @@ def _wizard_agents_and_tasks( return agents, tasks, crew_settings +def _default_agents_and_tasks( + default_llm: str | None = None, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]: + """Return deterministic scaffold data for non-interactive project creation.""" + llm = default_llm or "openai/gpt-4o" + agents = [ + { + "name": "researcher", + "role": "Senior Researcher", + "goal": "Research the requested topic and identify useful findings.", + "backstory": ( + "You are an experienced researcher who finds relevant information " + "and presents it clearly." + ), + "llm": llm, + "tools": [], + "planning": False, + "allow_delegation": False, + } + ] + tasks = [ + { + "name": "research_task", + "description": "Research current AI trends and write a concise summary.", + "expected_output": "A concise markdown report with key findings.", + "agent": "researcher", + "context": [], + } + ] + crew_settings = { + "process": "sequential", + "memory": False, + "inputs": {}, + } + return agents, tasks, crew_settings + + # ── JSONC generation from wizard data ────────────────────────── @@ -1029,7 +1071,9 @@ def create_json_crew( import keyword import shutil - enable_prompt_line_editing() + dmn_mode = is_dmn_mode_enabled() + if not dmn_mode: + enable_prompt_line_editing() name = name.rstrip("/") if not name.strip(): @@ -1048,6 +1092,8 @@ def create_json_crew( folder_path = Path(folder_name) if folder_path.exists(): + if dmn_mode: + raise click.ClickException(f"Folder {folder_name} already exists.") if not click.confirm(f"Folder {folder_name} already exists. Override?"): click.secho("Cancelled.", fg="yellow") sys.exit(0) @@ -1056,10 +1102,14 @@ def create_json_crew( click.echo() click.secho(f" Creating crew: {name}", fg="green", bold=True) - agents, tasks, crew_settings = _wizard_agents_and_tasks( - skip_provider=skip_provider, - default_llm=_default_model_for_provider(provider), - ) + default_llm = _default_model_for_provider(provider) + if dmn_mode: + agents, tasks, crew_settings = _default_agents_and_tasks(default_llm) + else: + agents, tasks, crew_settings = _wizard_agents_and_tasks( + skip_provider=skip_provider, + default_llm=default_llm, + ) # Create directories folder_path.mkdir(parents=True) @@ -1104,7 +1154,7 @@ def create_json_crew( (folder_path / "skills" / ".gitkeep").write_text("", encoding="utf-8") # Setup .env with API keys - if not skip_provider: + if not skip_provider and not dmn_mode: models = list({a["llm"] for a in agents}) for model in models: _setup_env(folder_path, model) diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index 51420a3d4..cbc14ff86 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -17,6 +17,7 @@ from packaging import version from crewai_cli.utils import ( build_env_with_all_tool_credentials, enable_prompt_line_editing, + is_dmn_mode_enabled, read_toml, ) from crewai_cli.version import get_crewai_version @@ -202,6 +203,35 @@ 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: + """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) + 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) -> Any: """Load and run a JSON-defined crew.""" from dotenv import load_dotenv @@ -219,6 +249,9 @@ def _run_json_crew(trained_agents_file: str | None = None) -> Any: if crew_path is None: raise FileNotFoundError("No crew.jsonc or crew.json found") + if is_dmn_mode_enabled(): + return _run_json_crew_without_tui(crew_path) + crew_run_app_cls, crew, default_inputs, task_names, agent_names = ( _load_json_crew_for_tui(crew_path) ) diff --git a/lib/cli/src/crewai_cli/utils.py b/lib/cli/src/crewai_cli/utils.py index 386478c8c..0834c1851 100644 --- a/lib/cli/src/crewai_cli/utils.py +++ b/lib/cli/src/crewai_cli/utils.py @@ -29,6 +29,7 @@ __all__ = [ "get_project_description", "get_project_name", "get_project_version", + "is_dmn_mode_enabled", "load_env_vars", "parse_toml", "read_toml", @@ -41,6 +42,14 @@ __all__ = [ console = Console() +def is_dmn_mode_enabled() -> bool: + """Return True when the enterprise non-interactive mode is enabled.""" + value = os.environ.get("CREWAI_DMN") + if value is None: + return False + return value.strip().lower() not in {"", "0", "false", "no", "off"} + + def enable_prompt_line_editing() -> None: """Enable cursor movement/history editing for Click text prompts when available.""" try: diff --git a/lib/cli/tests/test_cli.py b/lib/cli/tests/test_cli.py index a8cab30b9..3b5ce277f 100644 --- a/lib/cli/tests/test_cli.py +++ b/lib/cli/tests/test_cli.py @@ -4,6 +4,7 @@ from unittest import mock import pytest from click.testing import CliRunner from crewai_cli.cli import ( + create, deploy_create, deploy_list, deploy_logs, @@ -157,6 +158,28 @@ def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, ru run_crew.assert_not_called() +@mock.patch("crewai_cli.create_json_crew.create_json_crew") +def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner): + result = runner.invoke(create, ["crew", "DMN Crew"], env={"CREWAI_DMN": "True"}) + + assert result.exit_code == 0 + create_json_crew.assert_called_once_with("DMN Crew", None, True) + + +def test_create_requires_type_in_dmn_mode(runner): + result = runner.invoke(create, env={"CREWAI_DMN": "True"}) + + assert result.exit_code == 2 + assert "TYPE is required when CREWAI_DMN is set" in result.output + + +def test_create_requires_name_in_dmn_mode(runner): + result = runner.invoke(create, ["flow"], env={"CREWAI_DMN": "True"}) + + assert result.exit_code == 2 + assert "NAME is required when CREWAI_DMN is set" in result.output + + @mock.patch("crewai_cli.cli.AuthenticationCommand") def test_login(command, runner): mock_auth = command.return_value diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py index c6ed1a039..bb4bc68be 100644 --- a/lib/cli/tests/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -812,3 +812,34 @@ def test_json_wizard_task_reprompts_on_cancelled_agent_pick(monkeypatch): assert len(pick_calls) == 2 assert task["agent"] == "second_agent" + + +def test_json_create_dmn_mode_uses_non_interactive_defaults(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CREWAI_DMN", "True") + monkeypatch.setattr( + json_crew, + "_wizard_agents_and_tasks", + lambda **_: pytest.fail("DMN mode must not run the wizard"), + ) + monkeypatch.setattr( + json_crew, + "_setup_env", + lambda *_args, **_kwargs: pytest.fail("DMN mode must not prompt for env vars"), + ) + + json_crew.create_json_crew("DMN Crew", provider="anthropic", skip_provider=False) + + project_root = tmp_path / "dmn_crew" + assert (project_root / "crew.jsonc").exists() + assert (project_root / "agents" / "researcher.jsonc").exists() + assert not (project_root / ".env").exists() + + crew_template = (project_root / "crew.jsonc").read_text() + agent_template = (project_root / "agents" / "researcher.jsonc").read_text() + + assert '"memory": false' in crew_template + assert '"description": "Research current AI trends and write a concise summary."' in ( + crew_template + ) + assert '"llm": "anthropic/claude-opus-4-6"' in agent_template diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index d6565c2cc..88d31fa1e 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -402,6 +402,7 @@ def test_missing_input_names_accepts_hyphenated_placeholders(): def _patch_tui_run(monkeypatch, status: str): """Stub the TUI pieces of _run_json_crew so only exit handling runs.""" + monkeypatch.delenv("CREWAI_DMN", raising=False) class FakeApp: def __init__(self, **kwargs): @@ -446,6 +447,84 @@ def test_run_json_crew_completed_status_returns_result(monkeypatch, tmp_path: Pa assert run_crew_module._run_json_crew() == "result" +def test_run_json_crew_dmn_mode_bypasses_tui(monkeypatch, tmp_path: Path, capsys): + from types import SimpleNamespace + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CREWAI_DMN", "True") + crew_path = tmp_path / "crew.jsonc" + crew_path.write_text("{}") + kickoff_calls = [] + + class FakeCrew: + name = "Demo" + agents = [SimpleNamespace(role="Researcher", goal="Research", backstory="")] + tasks = [ + SimpleNamespace( + description="Research", + expected_output="Findings", + output_file=None, + ) + ] + + def kickoff(self, inputs): + kickoff_calls.append(inputs) + return "plain result" + + monkeypatch.setattr(run_crew_module, "find_crew_json_file", lambda: crew_path) + monkeypatch.setattr( + run_crew_module, + "_load_json_crew", + lambda _path: (FakeCrew(), {"topic": "AI"}), + ) + monkeypatch.setattr( + run_crew_module, + "_load_json_crew_for_tui", + lambda _path: pytest.fail("DMN mode must not start the TUI loader"), + ) + + assert run_crew_module._run_json_crew() == "plain result" + + captured = capsys.readouterr() + assert kickoff_calls == [{"topic": "AI"}] + assert "plain result" in captured.out + + +def test_run_json_crew_dmn_mode_exits_on_missing_inputs( + monkeypatch, tmp_path: Path, capsys +): + from types import SimpleNamespace + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CREWAI_DMN", "True") + crew_path = tmp_path / "crew.jsonc" + crew_path.write_text("{}") + crew = SimpleNamespace( + agents=[ + SimpleNamespace( + role="Researcher", + goal="Research {topic}", + backstory="", + ) + ], + tasks=[], + ) + + monkeypatch.setattr(run_crew_module, "find_crew_json_file", lambda: crew_path) + monkeypatch.setattr( + run_crew_module, + "_load_json_crew", + lambda _path: (crew, {}), + ) + + with pytest.raises(SystemExit) as exc_info: + run_crew_module._run_json_crew() + + captured = capsys.readouterr() + assert exc_info.value.code == 1 + assert "Missing runtime inputs for CREWAI_DMN mode: topic" in captured.err + + def test_has_json_crew_defers_to_declared_flow_type(monkeypatch, tmp_path: Path): """A flow project containing a stray crew.jsonc must still run as a flow.""" monkeypatch.chdir(tmp_path) diff --git a/lib/cli/tests/test_utils.py b/lib/cli/tests/test_utils.py index 0e5695054..ae8f37c9f 100644 --- a/lib/cli/tests/test_utils.py +++ b/lib/cli/tests/test_utils.py @@ -102,6 +102,23 @@ def test_tree_copy_to_existing_directory(temp_tree): shutil.rmtree(dest_dir) +@pytest.mark.parametrize("value", ["1", "true", "True", "yes", "anything"]) +def test_is_dmn_mode_enabled_for_truthy_values(monkeypatch, value): + monkeypatch.setenv("CREWAI_DMN", value) + + assert utils.is_dmn_mode_enabled() is True + + +@pytest.mark.parametrize("value", [None, "", "0", "false", "no", "off"]) +def test_is_dmn_mode_enabled_for_falsey_values(monkeypatch, value): + if value is None: + monkeypatch.delenv("CREWAI_DMN", raising=False) + else: + monkeypatch.setenv("CREWAI_DMN", value) + + assert utils.is_dmn_mode_enabled() is False + + # Tests for extract_available_exports, get_crews, get_flows, fetch_crews, # is_valid_tool live in lib/crewai/tests/cli/test_utils.py — the canonical # implementations are in crewai.utilities.project_utils.