diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index f9948a2978..ae201cc500 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re +import shlex import subprocess import sys from typing import TYPE_CHECKING, Any, cast @@ -17,6 +18,7 @@ build_env_with_all_tool_credentials, enable_prompt_line_editing, is_dmn_mode_enabled, + parse_toml, read_toml, ) from crewai_cli.version import get_crewai_version @@ -604,12 +606,14 @@ def _run_flow_project( run_declarative_flow_in_project_env(definition=definition) return + _ensure_project_script(pyproject_data, "kickoff", entity_type="flow") _execute_uv_script("kickoff", entity_type="flow") def _run_classic_crew_project( pyproject_data: dict[str, Any], trained_agents_file: str | None ) -> None: + _ensure_project_script(pyproject_data, "run_crew", entity_type="crew") _execute_uv_script( "run_crew", entity_type="crew", @@ -622,6 +626,75 @@ def _get_project_type(pyproject_data: dict[str, Any]) -> str | None: return project_type if isinstance(project_type, str) else None +def _project_scripts(pyproject_data: dict[str, Any]) -> dict[str, Any]: + project = pyproject_data.get("project", {}) + if not isinstance(project, dict): + return {} + scripts = project.get("scripts", {}) + return scripts if isinstance(scripts, dict) else {} + + +def _ensure_project_script( + pyproject_data: dict[str, Any], script_name: str, *, entity_type: str +) -> None: + if script_name in _project_scripts(pyproject_data): + return + + cwd = Path.cwd() + child_project_hint = _child_project_run_hint(script_name) + raise click.ClickException( + f"Cannot run this {entity_type} from {cwd}: pyproject.toml does not " + f"define the `{script_name}` project script.\n\n" + f"Run `crewai run` from a CrewAI {entity_type} project directory, or add " + f"`{script_name}` under `[project.scripts]` in pyproject.toml." + f"{child_project_hint}" + ) + + +def _child_project_run_hint(script_name: str) -> str: + child_projects = _find_child_projects_with_script(script_name) + if not child_projects: + return "" + + if len(child_projects) == 1: + project_path = _relative_child_project_path(child_projects[0]) + project_command = shlex.quote(str(project_path)) + return ( + f"\n\nFound a CrewAI project in `{project_path}`. Try:\n\n" + f" cd {project_command}\n" + " crewai run" + ) + + project_list = "\n".join( + f" - `{_relative_child_project_path(project_path)}`" + for project_path in child_projects + ) + return ( + "\n\nFound CrewAI projects with the expected script:\n" + f"{project_list}\n\n" + "Run `crewai run` from one of those directories." + ) + + +def _find_child_projects_with_script(script_name: str) -> list[Path]: + projects: list[Path] = [] + for pyproject_path in sorted(Path.cwd().glob("*/pyproject.toml")): + try: + pyproject_data = parse_toml(pyproject_path.read_text()) + except (OSError, ValueError): + continue + if script_name in _project_scripts(pyproject_data): + projects.append(pyproject_path.parent) + return projects + + +def _relative_child_project_path(project_path: Path) -> Path: + try: + return project_path.relative_to(Path.cwd()) + except ValueError: + return project_path + + def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None: crewai_version = get_crewai_version() min_required_version = "0.71.0" diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index c51fc16c56..0e8bbb4481 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -612,7 +612,10 @@ def test_run_crew_runs_classic_crew_project(monkeypatch, capsys): monkeypatch.setattr( run_crew_module, "read_toml", - lambda: {"tool": {"crewai": {"type": "crew"}}}, + lambda: { + "project": {"scripts": {"run_crew": "demo.main:run"}}, + "tool": {"crewai": {"type": "crew"}}, + }, ) monkeypatch.setattr( run_crew_module, @@ -631,6 +634,83 @@ def test_run_crew_runs_classic_crew_project(monkeypatch, capsys): ] +def test_run_crew_allows_legacy_project_with_run_script(monkeypatch): + calls = [] + + monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) + monkeypatch.setattr( + run_crew_module, + "read_toml", + lambda: {"project": {"scripts": {"run_crew": "demo.main:run"}}}, + ) + monkeypatch.setattr( + run_crew_module, + "_execute_uv_script", + lambda script_name, **kwargs: calls.append((script_name, kwargs)), + ) + + run_crew_module.run_crew() + + assert calls == [ + ( + "run_crew", + {"entity_type": "crew", "trained_agents_file": None}, + ) + ] + + +def test_run_crew_rejects_project_without_run_script(monkeypatch, tmp_path: Path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) + monkeypatch.setattr( + run_crew_module, + "read_toml", + lambda: {"project": {"name": "demo"}, "tool": {"crewai": {"type": "crew"}}}, + ) + monkeypatch.setattr( + run_crew_module, + "_execute_uv_script", + lambda *_args, **_kwargs: pytest.fail("missing script must not invoke uv"), + ) + + with pytest.raises(click.ClickException) as exc_info: + run_crew_module.run_crew() + + message = exc_info.value.message + assert "does not define the `run_crew` project script" in message + assert "Run `crewai run` from a CrewAI crew project directory" in message + + +def test_run_crew_rejects_project_without_run_script_suggests_child_project( + monkeypatch, tmp_path: Path +): + monkeypatch.chdir(tmp_path) + child_project = tmp_path / "ai_agent_news" + child_project.mkdir() + (child_project / "pyproject.toml").write_text( + "[project.scripts]\nrun_crew = 'ai_agent_news.main:run'\n" + ) + monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) + monkeypatch.setattr( + run_crew_module, + "read_toml", + lambda: {"project": {"name": "demo"}, "tool": {"crewai": {"type": "crew"}}}, + ) + monkeypatch.setattr( + run_crew_module, + "_execute_uv_script", + lambda *_args, **_kwargs: pytest.fail("missing script must not invoke uv"), + ) + + with pytest.raises(click.ClickException) as exc_info: + run_crew_module.run_crew() + + message = exc_info.value.message + assert "Found a CrewAI project in `ai_agent_news`" in message + assert "cd ai_agent_news" in message + assert "crewai run" in message + + def test_run_crew_runs_python_flow_project(monkeypatch, capsys): calls = [] @@ -638,7 +718,10 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys): monkeypatch.setattr( run_crew_module, "read_toml", - lambda: {"tool": {"crewai": {"type": "flow"}}}, + lambda: { + "project": {"scripts": {"kickoff": "demo.main:kickoff"}}, + "tool": {"crewai": {"type": "flow"}}, + }, ) monkeypatch.setattr( run_crew_module, @@ -652,6 +735,30 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys): assert calls == [("kickoff", {"entity_type": "flow"})] +def test_run_flow_rejects_project_without_kickoff_script( + monkeypatch, tmp_path: Path +): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) + monkeypatch.setattr( + run_crew_module, + "read_toml", + lambda: {"project": {"name": "demo"}, "tool": {"crewai": {"type": "flow"}}}, + ) + monkeypatch.setattr( + run_crew_module, + "_execute_uv_script", + lambda *_args, **_kwargs: pytest.fail("missing script must not invoke uv"), + ) + + with pytest.raises(click.ClickException) as exc_info: + run_crew_module.run_crew() + + message = exc_info.value.message + assert "does not define the `kickoff` project script" in message + assert "Run `crewai run` from a CrewAI flow project directory" in message + + def test_run_crew_rejects_filename_for_flow_project(monkeypatch): monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) monkeypatch.setattr(