Compare commits

..

4 Commits

Author SHA1 Message Date
Vidit-Ostwal
2c8a3d2580 docs(cli): clarify @listen self-reference fails at validation
Document that matching @listen labels to handler names raises a validation
error at flow instantiation, and that the runtime loop only occurs if
validation is bypassed.
2026-08-05 23:16:25 +05:30
Vidit-Ostwal
b5c9df3d93 docs: update scaffold AGENTS.md for unified create CLI
Document crewai create tool/skill/template, lifecycle commands, and
deprecated scaffolding aliases in the project template AGENTS.md.
2026-08-05 23:10:35 +05:30
Vidit Ostwal
fef32f43a0 feat(cli): unify scaffolding under crewai create <resource> (#6821)
* feat(cli): add canonical `crewai create tool` command

Unify tool scaffolding under the create verb and deprecate
`crewai tool create` with a yellow warning while keeping backward
compatibility.

* feat(cli): add canonical `crewai create skill` command

Unify skill scaffolding under the create verb and deprecate
`crewai skill create` with a yellow warning while keeping backward
compatibility.

* feat(cli): add canonical `crewai create template` command

Unify template scaffolding under the create verb and deprecate
`crewai template add` with a yellow warning while keeping backward
compatibility.

* docs: document unified `crewai create` scaffolding commands

Document canonical create forms for tool, skill, and template projects,
note deprecated aliases, and update skills and agents-md guides.

* feat(cli): extend create picker and DMN guidance for all types

Show tool, skill, and template in the interactive create picker and
list every supported type in the CREWAI_DMN usage error.

* fix(cli): allow create tool/skill/template in CREWAI_DMN mode

Only set skip_provider in DMN mode for crew creation, since tool,
skill, and template paths reject that flag as a crew-only option.

* test(cli): patch TemplateCommand at cli lookup site in DMN test

create() resolves TemplateCommand from crewai_cli.cli, not from
remote_template.main directly.
2026-08-05 16:41:27 +00:00
Rip&Tear
a3351d153d ci: report required test check names on non-code PRs (#6822)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / Detect changes (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Skipped matrix jobs never post status for branch-protection names like
tests (3.10). Add lightweight skip jobs with the same names so Actions-only
and docs-only PRs can merge without waiting forever.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rip&Tear <theCyberTech@users.noreply.github.com>
2026-08-05 19:46:19 +08:00
16 changed files with 478 additions and 245 deletions

View File

@@ -122,11 +122,25 @@ jobs:
.venv
key: uv-main-py${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}
# Report the required check names (tests 3.103.13) when the matrix is skipped.
# Branch protection expects these names; a skipped matrix never reports them.
tests-skip:
name: tests (${{ matrix.python-version }})
needs: changes
if: needs.changes.outputs.code != 'true'
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- name: Skip non-code change
run: echo "Non-code change, skipping tests"
# Summary job to provide single status for branch protection
tests:
name: tests
runs-on: ubuntu-latest
needs: [changes, tests-matrix]
needs: [changes, tests-matrix, tests-skip]
if: always()
steps:
- name: Check results

View File

@@ -76,11 +76,25 @@ jobs:
.venv
key: uv-main-py${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}
# Report the required check names when the matrix is skipped.
# Branch protection expects these names; a skipped matrix never reports them.
type-checker-skip:
name: type-checker (${{ matrix.python-version }})
needs: changes
if: needs.changes.outputs.code != 'true'
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- name: Skip non-code change
run: echo "Non-code change, skipping type checks"
# Summary job to provide single status for branch protection
type-checker:
name: type-checker
runs-on: ubuntu-latest
needs: [changes, type-checker-matrix]
needs: [changes, type-checker-matrix, type-checker-skip]
if: always()
steps:
- name: Check results

View File

@@ -36,24 +36,73 @@ crewai [COMMAND] [OPTIONS] [ARGUMENTS]
### 1. Create
Create a new crew or flow.
Create a new crew, flow, tool, skill, or template project.
```shell Terminal
crewai create [OPTIONS] TYPE NAME
```
- `TYPE`: Choose between "crew" or "flow"
- `NAME`: Name of the crew or flow
- `TYPE`: `crew`, `flow`, `tool`, `skill`, or `template`
- `NAME`: Name of the project, tool handle, skill, or template
Example:
#### Crew
```shell Terminal
crewai create crew my_new_crew
crewai create flow my_new_flow
crewai create crew my_new_crew --classic
```
By default, `crewai create crew` creates a JSON-first crew project with `crew.jsonc` and `agents/*.jsonc`. Use `crewai create crew my_new_crew --classic` only when you want the older Python/YAML scaffold with `crew.py`, `config/agents.yaml`, and `config/tasks.yaml`.
#### Flow
```shell Terminal
crewai create flow my_new_flow
crewai create flow my_new_flow --declarative
```
#### Tool
Scaffold a custom tool repository:
```shell Terminal
crewai create tool my_tool
```
#### Skill
Scaffold an agent skill. Inside a crew project (where `pyproject.toml` exists), the skill is created under `./skills/`:
```shell Terminal
crewai create skill my-skill
crewai create skill my-skill --no-project
```
Use `--no-project` to create the skill in the current directory instead of `./skills/`.
#### Template
Add a remote project template to the current directory:
```shell Terminal
crewai create template my-template
crewai create template my-template --output-dir custom_dir
```
Use `--output-dir` to override the output folder name (defaults to the template name).
#### Deprecated create aliases
These older commands still work but print a yellow deprecation warning. Prefer the `crewai create <type>` forms above.
| Deprecated | Use instead |
| :--- | :--- |
| `crewai tool create <handle>` | `crewai create tool <handle>` |
| `crewai skill create <name>` | `crewai create skill <name>` |
| `crewai template add <name>` | `crewai create template <name>` |
Lifecycle commands are unchanged — for example `crewai tool install`, `crewai skill publish`, and `crewai template list` stay under their resource groups.
### 2. Version
Show the installed version of CrewAI.

View File

@@ -32,10 +32,10 @@ You often need **both**: skills for expertise, tools for action. They are config
The CLI is the supported way to create a skill — it scaffolds the directory layout and a valid `SKILL.md` for you:
```shell Terminal
crewai skill create code-review
crewai create skill code-review
```
Inside a crew project (where `pyproject.toml` lives) this creates `./skills/code-review/`; outside a project it creates `./code-review/` in the current directory (you can force that behavior with `--no-project`):
Inside a crew project (where `pyproject.toml` lives) this creates `./skills/code-review/`; outside a project it creates `./code-review/` in the current directory (you can force that behavior with `--no-project` on `crewai create skill`):
```
skills/
@@ -178,12 +178,16 @@ agent = Agent(
## Creating, Publishing, and Installing Skills
Skills have a full lifecycle managed by the CLI: **create them with `crewai skill create`, publish them with `crewai skill publish`** — hand-rolling directories works for local experiments, but the CLI is the intended workflow and keeps your skill layout and frontmatter valid.
Skills have a full lifecycle managed by the CLI: **create them with `crewai create skill`, publish them with `crewai skill publish`** — hand-rolling directories works for local experiments, but the CLI is the intended workflow and keeps your skill layout and frontmatter valid.
<Note>
`crewai skill create` is deprecated and still works with a warning. Use `crewai create skill` instead.
</Note>
### Create
```shell Terminal
crewai skill create my-skill
crewai create skill my-skill
```
Scaffolds the directory (into `./skills/` inside a crew project) with a template `SKILL.md`, plus empty `scripts/`, `references/`, and `assets/` directories. Edit `SKILL.md` to define the instructions.

View File

@@ -21,9 +21,13 @@ crewai create crew my_crew
crewai create flow my_flow
# Tool repository
crewai tool create my_tool
crewai create tool my_tool
```
<Note>
`crewai tool create` is deprecated and still works with a warning. Use `crewai create tool` instead.
</Note>
## Tool Setup: Point Assistants to AGENTS.md
### Codex

View File

@@ -19,6 +19,7 @@ from crewai_cli.utils import (
enable_prompt_line_editing,
is_dmn_mode_enabled,
read_toml,
warn_deprecated_command,
)
@@ -137,7 +138,10 @@ def uv(uv_args: tuple[str, ...]) -> None:
@crewai.command()
@click.argument(
"type", required=False, default=None, type=click.Choice(["crew", "flow"])
"type",
required=False,
default=None,
type=click.Choice(["crew", "flow", "tool", "skill", "template"]),
)
@click.argument("name", required=False, default=None)
@click.option("--provider", type=str, help="The provider to use for the crew")
@@ -152,6 +156,21 @@ def uv(uv_args: tuple[str, ...]) -> None:
is_flag=True,
help="Create a declarative Flow project instead of a Python Flow project",
)
@click.option(
"--no-project",
"in_project",
is_flag=True,
default=True,
flag_value=False,
help="Skill only: create in current dir instead of ./skills/",
)
@click.option(
"-o",
"--output-dir",
type=str,
default=None,
help="Template only: directory name for the template (defaults to template name)",
)
def create(
type: str | None,
name: str | None,
@@ -159,14 +178,17 @@ def create(
skip_provider: bool = False,
classic: bool = False,
declarative: bool = False,
in_project: bool = True,
output_dir: str | None = None,
) -> None:
"""Create a new crew, or flow."""
"""Create a new crew, flow, tool, skill, or template."""
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>`."
"Use `crewai create <type> <name>` where type is one of: "
"crew, flow, tool, skill, template."
)
from crewai_cli.tui_picker import pick
@@ -176,6 +198,9 @@ def create(
"flow",
"A deterministic workflow with full control over agents and crews",
),
("tool", "A custom tool for the CrewAI Tool Repository"),
("skill", "An agent skill with instructions and optional assets"),
("template", "A remote project template from the CrewAI gallery"),
]
type = pick("What would you like to create?", options)
if type is None:
@@ -189,9 +214,36 @@ def create(
click.style(f" Name of your {type}", fg="cyan", bold=True),
prompt_suffix=click.style(" ", fg="bright_white"), # noqa: RUF001
)
if dmn_mode:
if dmn_mode and type == "crew":
skip_provider = True
if type == "crew":
if not in_project and type != "skill":
raise click.UsageError("--no-project can only be used with skill projects.")
if output_dir is not None and type != "template":
raise click.UsageError("--output-dir can only be used with template projects.")
if type == "tool":
if declarative or classic or provider is not None or skip_provider:
raise click.UsageError(
"Crew and flow options cannot be used with tool projects."
)
from crewai_cli.tools.main import ToolCommand
ToolCommand().create(name)
elif type == "skill":
if declarative or classic or provider is not None or skip_provider:
raise click.UsageError(
"Crew and flow options cannot be used with skill projects."
)
from crewai_cli.skills.main import SkillCommand
SkillCommand().create(name, in_project=in_project)
elif type == "template":
if declarative or classic or provider is not None or skip_provider:
raise click.UsageError(
"Crew and flow options cannot be used with template projects."
)
template_cmd = TemplateCommand()
template_cmd.add_template(name, output_dir)
elif type == "crew":
if declarative:
raise click.UsageError("--declarative can only be used with flow projects")
if classic:
@@ -207,7 +259,10 @@ def create(
create_flow(name, declarative=declarative)
else:
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
click.secho(
"Error: Invalid type. Must be 'crew', 'flow', 'tool', 'skill', or 'template'.",
fg="red",
)
@crewai.command()
@@ -652,6 +707,8 @@ def tool() -> None:
@tool.command(name="create")
@click.argument("handle")
def tool_create(handle: str) -> None:
"""[Deprecated: use `crewai create tool`] Create a custom tool project."""
warn_deprecated_command(old="crewai tool create", new="crewai create tool")
from crewai_cli.tools.main import ToolCommand
tool_cmd = ToolCommand()
@@ -702,6 +759,8 @@ def skill() -> None:
help="Create skill in current dir instead of ./skills/",
)
def skill_create(name: str, in_project: bool) -> None:
"""[Deprecated: use `crewai create skill`] Create a new agent skill."""
warn_deprecated_command(old="crewai skill create", new="crewai create skill")
from crewai_cli.skills.main import SkillCommand
skill_cmd = SkillCommand()
@@ -765,7 +824,8 @@ def template_list() -> None:
help="Directory name for the template (defaults to template name)",
)
def template_add(name: str, output_dir: str | None) -> None:
"""Add a template to the current directory."""
"""[Deprecated: use `crewai create template`] Add a template to the current directory."""
warn_deprecated_command(old="crewai template add", new="crewai create template")
template_cmd = TemplateCommand()
template_cmd.add_template(name, output_dir)

View File

@@ -40,6 +40,14 @@ This ensures generated code always matches the version actually installed, not s
-`Agent(llm=ChatOpenAI(...))` → ✅ `Agent(llm="openai/gpt-4o")` or `Agent(llm=LLM(model="..."))`
- ❌ Passing raw OpenAI client objects → ✅ Use `crewai.LLM` wrapper
### Deprecated CLI scaffolding aliases (still supported)
These commands remain supported but print a yellow deprecation warning. Prefer the canonical forms:
- ⚠️ `crewai tool create <handle>` → ✅ `crewai create tool <handle>`
- ⚠️ `crewai skill create <name>` → ✅ `crewai create skill <name>`
- ⚠️ `crewai template add <name>` → ✅ `crewai create template <name>`
### How to verify you're using current patterns:
1. You ran the version check and docs lookup steps above before writing code
2. All LLM references use `crewai.LLM` or string shorthand (`"openai/gpt-4o"`)
@@ -137,8 +145,26 @@ uv sync # Sync dependencies
uv lock # Lock dependencies
# Project scaffolding
crewai create crew <name> --skip_provider # New crew project
crewai create flow <name> --skip_provider # New flow project
crewai create crew <name> --skip_provider # New crew project
crewai create flow <name> # New flow project
crewai create tool <handle> # Custom tool repository
crewai create skill <name> # Agent skill (./skills/ in crew projects)
crewai create skill <name> --no-project # Skill in current directory
crewai create template <name> # Remote project template
crewai create template <name> -o <output_dir> # Template with custom output directory
# Deprecated scaffolding aliases (still work; print a yellow warning)
# crewai tool create <handle> → crewai create tool <handle>
# crewai skill create <name> → crewai create skill <name>
# crewai template add <name> → crewai create template <name>
# Tool, skill, and template lifecycle (unchanged)
crewai tool install <handle>
crewai tool publish
crewai skill install @org/name
crewai skill publish
crewai skill list
crewai template list
# Running
crewai run # Run crew or flow (auto-detects from pyproject.toml)
@@ -627,6 +653,26 @@ class MyFlow(Flow):
| `@listen(method)` | Triggers when specified method completes. Receives output as argument |
| `@router(method)` | Conditional branching. Returns string labels that trigger `@listen("label")` |
### `@listen` labels vs handler names
The string in `@listen("...")` is an **event or route label**, not the Python method name. Router return values, route labels, and method completion events share one trigger namespace.
**Never** use the same name for the `@listen` label and the handler method:
```python
# ❌ Wrong — raises a validation error when the flow is instantiated
@listen("create_video")
def create_video(self):
...
# ✅ Correct — distinct handler name (handle_* prefix is a common pattern)
@listen("create_video")
def handle_create_video(self):
...
```
If validation were bypassed, matching names would also cause the handler to re-trigger itself in a loop at runtime. This applies to all flows; it is especially common in **conversational flows** (`conversational = True`), where `@listen("...")` is a router intent name.
### Structured State
```python
from pydantic import BaseModel
@@ -1113,7 +1159,9 @@ Python >=3.10, <3.14
```bash
uv tool install crewai # Install CrewAI CLI
uv tool list # Verify installation
crewai create crew my_crew --skip_provider # Scaffold a new project
crewai create crew my_crew --skip_provider # Scaffold a crew project
crewai create tool my_tool # Scaffold a tool repository
crewai create skill my_skill # Scaffold an agent skill
crewai install # Install project dependencies
crewai run # Execute
```
@@ -1148,3 +1196,4 @@ crewai run # Execute
- Using `process=Process.hierarchical` without setting `manager_llm` or `manager_agent`
- Circular delegation: set `allow_delegation=False` on specialist agents
- Not installing tools package: `uv add crewai-tools`
- **Matching `@listen("label")` to the handler method name** — raises a validation error at flow instantiation; would re-trigger in an infinite loop at runtime only if validation is bypassed. Use a different method name (e.g. `handle_create_video` for `@listen("create_video")`)

View File

@@ -44,10 +44,19 @@ __all__ = [
"render_template",
"tree_copy",
"tree_find_and_replace",
"warn_deprecated_command",
"write_env_file",
]
def warn_deprecated_command(*, old: str, new: str) -> None:
"""Print a yellow deprecation warning for a legacy CLI command path."""
click.secho(
f"Warning: The command '{old}' is deprecated. Use '{new}' instead.",
fg="yellow",
)
console = Console()
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")

View File

@@ -228,6 +228,7 @@ def test_create_requires_type_in_dmn_mode(runner):
assert result.exit_code == 2
assert "TYPE is required when CREWAI_DMN is set" in result.output
assert "crew, flow, tool, skill, template" in result.output
def test_create_requires_name_in_dmn_mode(runner):

View File

@@ -0,0 +1,230 @@
"""Tests for unified `crewai create <resource>` scaffolding commands."""
from unittest import mock
import pytest
from click.testing import CliRunner
from crewai_cli.cli import create, crewai
@pytest.fixture
def runner():
return CliRunner()
@mock.patch("crewai_cli.tools.main.ToolCommand")
def test_create_tool_invokes_tool_command(mock_tool_command_cls, runner):
result = runner.invoke(create, ["tool", "my_tool"])
assert result.exit_code == 0, result.output
mock_tool_command_cls.return_value.create.assert_called_once_with("my_tool")
assert "deprecated" not in result.output.lower()
@mock.patch("crewai_cli.tools.main.ToolCommand")
def test_tool_create_is_deprecated_and_still_works(mock_tool_command_cls, runner):
result = runner.invoke(crewai, ["tool", "create", "my_tool"])
assert result.exit_code == 0, result.output
mock_tool_command_cls.return_value.create.assert_called_once_with("my_tool")
assert (
"Warning: The command 'crewai tool create' is deprecated. "
"Use 'crewai create tool' instead."
in result.output
)
@mock.patch("crewai_cli.tools.main.ToolCommand")
@pytest.mark.parametrize("extra_args", [["--classic"], ["--declarative"], ["--provider", "openai"], ["--skip_provider"]])
def test_create_tool_rejects_crew_and_flow_flags(mock_tool_command_cls, runner, extra_args):
result = runner.invoke(create, ["tool", "my_tool", *extra_args])
assert result.exit_code == 2, result.output
assert "Crew and flow options cannot be used with tool projects." in result.output
mock_tool_command_cls.return_value.create.assert_not_called()
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_create_skill_invokes_skill_command(mock_skill_command_cls, runner):
result = runner.invoke(create, ["skill", "my-skill"])
assert result.exit_code == 0, result.output
mock_skill_command_cls.return_value.create.assert_called_once_with(
"my-skill", in_project=True
)
assert "deprecated" not in result.output.lower()
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_create_skill_no_project_flag(mock_skill_command_cls, runner):
result = runner.invoke(create, ["skill", "my-skill", "--no-project"])
assert result.exit_code == 0, result.output
mock_skill_command_cls.return_value.create.assert_called_once_with(
"my-skill", in_project=False
)
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_skill_create_is_deprecated_and_still_works(mock_skill_command_cls, runner):
result = runner.invoke(crewai, ["skill", "create", "my-skill"])
assert result.exit_code == 0, result.output
mock_skill_command_cls.return_value.create.assert_called_once_with(
"my-skill", in_project=True
)
assert (
"Warning: The command 'crewai skill create' is deprecated. "
"Use 'crewai create skill' instead."
in result.output
)
@mock.patch("crewai_cli.skills.main.SkillCommand")
@pytest.mark.parametrize(
"extra_args",
[["--classic"], ["--declarative"], ["--provider", "openai"], ["--skip_provider"]],
)
def test_create_skill_rejects_crew_and_flow_flags(
mock_skill_command_cls, runner, extra_args
):
result = runner.invoke(create, ["skill", "my-skill", *extra_args])
assert result.exit_code == 2, result.output
assert "Crew and flow options cannot be used with skill projects." in result.output
mock_skill_command_cls.return_value.create.assert_not_called()
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_create_crew_rejects_no_project_flag(mock_skill_command_cls, runner):
result = runner.invoke(create, ["crew", "my-crew", "--no-project"])
assert result.exit_code == 2, result.output
assert "--no-project can only be used with skill projects." in result.output
mock_skill_command_cls.return_value.create.assert_not_called()
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
def test_create_template_invokes_template_command(mock_template_command_cls, runner):
result = runner.invoke(create, ["template", "my-template"])
assert result.exit_code == 0, result.output
mock_template_command_cls.return_value.add_template.assert_called_once_with(
"my-template", None
)
assert "deprecated" not in result.output.lower()
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
def test_create_template_output_dir_flag(mock_template_command_cls, runner):
result = runner.invoke(
create, ["template", "my-template", "--output-dir", "custom_dir"]
)
assert result.exit_code == 0, result.output
mock_template_command_cls.return_value.add_template.assert_called_once_with(
"my-template", "custom_dir"
)
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
def test_template_add_is_deprecated_and_still_works(mock_template_command_cls, runner):
result = runner.invoke(
crewai, ["template", "add", "my-template", "--output-dir", "custom_dir"]
)
assert result.exit_code == 0, result.output
mock_template_command_cls.return_value.add_template.assert_called_once_with(
"my-template", "custom_dir"
)
assert (
"Warning: The command 'crewai template add' is deprecated. "
"Use 'crewai create template' instead."
in result.output
)
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
@pytest.mark.parametrize(
"extra_args",
[["--classic"], ["--declarative"], ["--provider", "openai"], ["--skip_provider"]],
)
def test_create_template_rejects_crew_and_flow_flags(
mock_template_command_cls, runner, extra_args
):
result = runner.invoke(create, ["template", "my-template", *extra_args])
assert result.exit_code == 2, result.output
assert (
"Crew and flow options cannot be used with template projects."
in result.output
)
mock_template_command_cls.return_value.add_template.assert_not_called()
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
def test_create_crew_rejects_output_dir_flag(mock_template_command_cls, runner):
result = runner.invoke(create, ["crew", "my-crew", "--output-dir", "custom_dir"])
assert result.exit_code == 2, result.output
assert "--output-dir can only be used with template projects." in result.output
mock_template_command_cls.return_value.add_template.assert_not_called()
@mock.patch("crewai_cli.cli.enable_prompt_line_editing")
@mock.patch("crewai_cli.cli.click.prompt", return_value="picked-tool")
@mock.patch("crewai_cli.tui_picker.pick", return_value="tool")
@mock.patch("crewai_cli.tools.main.ToolCommand")
def test_create_picker_supports_tool_skill_and_template(
mock_tool_command_cls,
mock_pick,
mock_prompt,
mock_enable_prompt,
runner,
):
result = runner.invoke(create, [])
assert result.exit_code == 0, result.output
mock_pick.assert_called_once()
picker_options = mock_pick.call_args[0][1]
assert {option[0] for option in picker_options} == {
"crew",
"flow",
"tool",
"skill",
"template",
}
mock_prompt.assert_called_once()
mock_tool_command_cls.return_value.create.assert_called_once_with("picked-tool")
_DMN_ENV = {"CREWAI_DMN": "True"}
@mock.patch("crewai_cli.tools.main.ToolCommand")
def test_create_tool_works_in_dmn_mode(mock_tool_command_cls, runner):
result = runner.invoke(create, ["tool", "my_tool"], env=_DMN_ENV)
assert result.exit_code == 0, result.output
mock_tool_command_cls.return_value.create.assert_called_once_with("my_tool")
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_create_skill_works_in_dmn_mode(mock_skill_command_cls, runner):
result = runner.invoke(create, ["skill", "my-skill"], env=_DMN_ENV)
assert result.exit_code == 0, result.output
mock_skill_command_cls.return_value.create.assert_called_once_with(
"my-skill", in_project=True
)
@mock.patch("crewai_cli.cli.TemplateCommand")
def test_create_template_works_in_dmn_mode(mock_template_command_cls, runner):
result = runner.invoke(create, ["template", "my-template"], env=_DMN_ENV)
assert result.exit_code == 0, result.output
mock_template_command_cls.return_value.add_template.assert_called_once_with(
"my-template", None
)

View File

@@ -183,24 +183,13 @@ class AzureCompletion(BaseLLM):
pass
return self
@staticmethod
def _openai_completion_class() -> Any:
"""Return the OpenAICompletion class used for Responses API delegation.
Isolated so tests can patch this lookup reliably under pytest-xdist
instead of racing the dynamic import inside ``_init_responses_delegate``.
"""
from crewai.llms.providers.openai.completion import OpenAICompletion
return OpenAICompletion
def _init_responses_delegate(self) -> None:
"""Create an OpenAICompletion delegate for the Azure OpenAI Responses API.
The Azure OpenAI Responses API uses the standard OpenAI Python SDK
with a base_url pointing to the Azure resource's /openai/v1/ endpoint.
"""
openai_completion_cls = self._openai_completion_class()
from crewai.llms.providers.openai.completion import OpenAICompletion
base_url = self._get_responses_base_url()
@@ -250,7 +239,7 @@ class AzureCompletion(BaseLLM):
if self.additional_params:
delegate_kwargs["additional_params"] = self.additional_params
self._responses_delegate = openai_completion_cls(**delegate_kwargs)
self._responses_delegate = OpenAICompletion(**delegate_kwargs)
def _get_responses_base_url(self) -> str:
"""Construct the base URL for the Azure OpenAI Responses API.

View File

@@ -10,7 +10,7 @@ from hashlib import md5
import inspect
import json
import logging
from pathlib import Path, PurePosixPath, PureWindowsPath
from pathlib import Path
import threading
from typing import (
Annotated,
@@ -517,29 +517,6 @@ class Task(BaseModel):
if value is None:
return None
if "{" in value or "}" in value:
template_vars = [part.split("}")[0] for part in value.split("{")[1:]]
for var in template_vars:
if not var.isidentifier():
raise ValueError(f"Invalid template variable name: {var}")
# Literal portions are still checked here; the fully interpolated
# path is re-validated at runtime (see
# interpolate_inputs_and_add_conversation_history) because template
# variables may be filled from untrusted kickoff inputs.
cls._sanitize_output_file_path(value)
return value
return cls._sanitize_output_file_path(value)
@staticmethod
def _sanitize_output_file_path(value: str) -> str:
"""Enforce path-safety on an ``output_file`` value.
Shared by the field validator's literal and template branches. Rejects
traversal sequences, shell expansion, and shell metacharacters, and
strips a leading ``/`` so a literal path stays relative to the working
directory.
"""
if ".." in value:
raise ValueError(
"Path traversal attempts are not allowed in output_file paths"
@@ -555,109 +532,17 @@ class Task(BaseModel):
"Shell special characters are not allowed in output_file paths"
)
if "{" in value or "}" in value:
template_vars = [part.split("}")[0] for part in value.split("{")[1:]]
for var in template_vars:
if not var.isidentifier():
raise ValueError(f"Invalid template variable name: {var}")
return value
if value.startswith("/"):
return value[1:]
return value
@staticmethod
def _is_unsafe_absolute_output_path(value: str) -> bool:
"""Return True if ``value`` is an absolute or drive/root-relative path.
Includes Windows drive-qualified relative paths (``C:foo``) and
root-relative paths (``\\Windows\\...``) that ``is_absolute()`` misses
but that still escape a relative working directory on Windows.
"""
windows_path = PureWindowsPath(value)
return bool(
PurePosixPath(value).is_absolute()
or windows_path.is_absolute()
or windows_path.drive
or windows_path.root
)
def _validate_output_file_input_values(
self, inputs: dict[str, str | int | float | dict[str, Any] | list[Any]]
) -> None:
"""Reject untrusted input values that would escape the output path.
Only the variables that actually appear in the ``output_file`` template
are checked. The developer-authored template is trusted (it may contain
an absolute base directory), but a value substituted into it must not
introduce path traversal (``..``), an absolute path, a home/variable
expansion (``~``/``$``), or shell metacharacters that would redirect the
write outside the intended location.
"""
if not self._original_output_file:
return
template_vars = [
part.split("}")[0] for part in self._original_output_file.split("{")[1:]
]
for var in template_vars:
if var not in inputs:
continue
value = str(inputs[var])
if ".." in value:
raise ValueError(
f"Invalid value for output_file variable '{var}': Path "
"traversal sequences ('..') are not allowed"
)
if value.startswith(("~", "$")):
raise ValueError(
f"Invalid value for output_file variable '{var}': Shell "
"expansion characters are not allowed"
)
if any(char in value for char in ["|", ">", "<", "&", ";"]):
raise ValueError(
f"Invalid value for output_file variable '{var}': Shell "
"special characters are not allowed"
)
if self._is_unsafe_absolute_output_path(value):
raise ValueError(
f"Invalid value for output_file variable '{var}': Absolute "
"paths are not allowed"
)
def _validate_interpolated_output_file(self, interpolated: str) -> None:
"""Reject a fully interpolated path that escapes the trusted template.
Per-value checks miss hazards formed by concatenating adjacent
placeholders (for example ``{a}{b}`` with ``a='.'`` and ``b='.'``).
The developer-authored template may include an absolute base directory;
that remains allowed when the interpolated result stays absolute for the
same reason. A relative template must not become absolute, and no
interpolated path may introduce traversal or shell metacharacters.
"""
template = self._original_output_file
if not template:
return
if ".." in interpolated:
raise ValueError(
"Path traversal attempts are not allowed in output_file paths"
)
if interpolated.startswith(("~", "$")):
raise ValueError(
"Shell expansion characters are not allowed in output_file paths"
)
if any(char in interpolated for char in ["|", ">", "<", "&", ";"]):
raise ValueError(
"Shell special characters are not allowed in output_file paths"
)
dummy_filled = template
for var in (part.split("}")[0] for part in template.split("{")[1:]):
dummy_filled = dummy_filled.replace("{" + var + "}", "_x_")
template_is_absolute = self._is_unsafe_absolute_output_path(dummy_filled)
if (
self._is_unsafe_absolute_output_path(interpolated)
and not template_is_absolute
):
raise ValueError(
"Absolute paths are not allowed in interpolated output_file paths"
)
@model_validator(mode="after")
def set_attributes_based_on_config(self) -> Task:
"""Set attributes based on the agent configuration."""
@@ -1216,23 +1101,12 @@ Follow these guidelines:
raise ValueError(f"Error interpolating expected_output: {e!s}") from e
if self.output_file is not None:
# Values interpolated into the output path may come from untrusted
# kickoff inputs. The developer-authored template (including any
# absolute base directory) is trusted, but an injected value must
# not introduce path traversal, an absolute path, or shell
# expansion that would escape the intended location. Per-value
# checks run first; the fully interpolated path is then checked so
# concatenated placeholders cannot form a hazard the individual
# values alone would miss.
self._validate_output_file_input_values(inputs)
try:
interpolated_output_file = interpolate_only(
self.output_file = interpolate_only(
input_string=self._original_output_file, inputs=inputs
)
except (KeyError, ValueError) as e:
raise ValueError(f"Error interpolating output_file path: {e!s}") from e
self._validate_interpolated_output_file(interpolated_output_file)
self.output_file = interpolated_output_file
if inputs.get("crew_chat_messages"):
conversation_instruction = I18N_DEFAULT.slice(

View File

@@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
from pydantic import BaseModel
from crewai.events.event_bus import crewai_event_bus
from crewai.events.event_bus import CrewAIEventsBus
from crewai.events.types.llm_events import LLMCallCompletedEvent, LLMCallType
from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
@@ -203,9 +203,7 @@ class _StubLLM(BaseLLM):
class TestEmitCallCompletedEventPassesUsage:
@pytest.fixture
def mock_emit(self):
# Patch the singleton instance; class-level patches are unreliable
# under pytest ``--import-mode=importlib`` / xdist.
with patch.object(crewai_event_bus, "emit") as mock:
with patch.object(CrewAIEventsBus, "emit") as mock:
yield mock
@pytest.fixture

View File

@@ -29,12 +29,9 @@ def azure_env():
def mock_openai_completion():
"""Mock OpenAICompletion to avoid real client creation.
Patches ``AzureCompletion._openai_completion_class`` so the Responses
delegate lookup is deterministic under pytest-xdist (patching the
dynamic import target alone can miss under parallel workers).
Patches at the source module so that the dynamic import inside
_init_responses_delegate picks up the mock.
"""
from crewai.llms.providers.azure.completion import AzureCompletion
instance = MagicMock()
instance.call = MagicMock(return_value="responses-result")
instance.acall = AsyncMock(return_value="async-responses-result")
@@ -44,10 +41,9 @@ def mock_openai_completion():
instance.reset_reasoning_chain = MagicMock()
mock_cls = MagicMock(return_value=instance)
with patch.object(
AzureCompletion,
"_openai_completion_class",
return_value=mock_cls,
with patch(
"crewai.llms.providers.openai.completion.OpenAICompletion",
mock_cls,
):
yield mock_cls, instance

View File

@@ -11,33 +11,22 @@ from unittest.mock import patch
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.event_bus import CrewAIEventsBus
from crewai.events.types.llm_events import LLMCallCompletedEvent
from crewai.llm import LLM
@pytest.fixture
def mock_emit():
# Patch the singleton instance (not the class). Class-level patches are
# unreliable under pytest ``--import-mode=importlib`` / xdist because the
# test and ``crewai.llm`` can observe different class objects.
with patch.object(crewai_event_bus, "emit") as mock:
with patch.object(CrewAIEventsBus, "emit") as mock:
yield mock
def _event_from_call(call) -> object | None:
if "event" in call.kwargs:
return call.kwargs["event"]
if len(call.args) >= 2:
return call.args[1]
return None
def _completed_event(mock_emit) -> LLMCallCompletedEvent:
matches = [
event
call.kwargs["event"]
for call in mock_emit.call_args_list
if isinstance((event := _event_from_call(call)), LLMCallCompletedEvent)
if isinstance(call.kwargs.get("event"), LLMCallCompletedEvent)
]
assert matches, "expected an LLMCallCompletedEvent to be emitted"
assert len(matches) == 1, f"expected one completed event, got {len(matches)}"

View File

@@ -932,53 +932,6 @@ def test_interpolate_inputs(tmp_path):
assert task.output_file == str(tmp_path / "ML" / "output_2025.txt")
@pytest.mark.parametrize(
("template", "malicious_inputs", "expected_error"),
[
("reports/{name}.md", {"name": "../../../../tmp/pwn"}, "Path traversal"),
("{p}", {"p": "/tmp/abs_pwn"}, "Absolute paths"),
("{p}", {"p": "~/.bashrc"}, "Shell expansion"),
("{p}", {"p": "x;rm -rf /"}, "Shell special characters"),
("{p}", {"p": r"C:\Windows\evil"}, "Absolute paths"),
# Drive-qualified relative path: not absolute() but escapes on Windows.
("{p}", {"p": r"C:Windows\evil"}, "Absolute paths"),
# Adjacent placeholders can concatenate into ".." even when each value
# alone looks safe.
("{a}{b}", {"a": ".", "b": "."}, "Path traversal"),
],
)
def test_interpolate_output_file_rejects_unsafe_inputs(
template, malicious_inputs, expected_error
):
"""Untrusted inputs must not escape the output_file path via interpolation."""
task = Task(
description="d",
expected_output="e",
output_file=template,
)
with pytest.raises(ValueError, match=expected_error):
task.interpolate_inputs_and_add_conversation_history(inputs=malicious_inputs)
def test_interpolate_output_file_allows_safe_inputs(tmp_path):
"""Safe input values and developer-chosen absolute base paths still work."""
task = Task(
description="d",
expected_output="e",
output_file="reports/{name}.md",
)
task.interpolate_inputs_and_add_conversation_history(inputs={"name": "q3_summary"})
assert task.output_file == "reports/q3_summary.md"
abs_task = Task(
description="d",
expected_output="e",
output_file=str(tmp_path / "{topic}" / "out.md"),
)
abs_task.interpolate_inputs_and_add_conversation_history(inputs={"topic": "sales"})
assert abs_task.output_file == str(tmp_path / "sales" / "out.md")
def test_interpolate_only():
"""Test the interpolate_only method for various scenarios including JSON structure preservation."""