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.
This commit is contained in:
Joao Moura
2026-06-16 14:56:47 -07:00
parent 06ada68083
commit 570c7e124f
9 changed files with 270 additions and 8 deletions

View File

@@ -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 <name>` or `crewai create flow <name>`."
)
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "1"})
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": "1"})
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": "1"})
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

View File

@@ -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", "1")
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

View File

@@ -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", "1")
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", "1")
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)

View File

@@ -102,6 +102,23 @@ def test_tree_copy_to_existing_directory(temp_tree):
shutil.rmtree(dest_dir)
@pytest.mark.parametrize("value", ["1", "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.