Compare commits

..

8 Commits

Author SHA1 Message Date
Rip&Tear
ee6e54233a docs: document FileWriterTool path confinement and CREWAI_TOOLS_ALLOWED_DIRS
Document the deny-by-default allow-list behavior, the new
CREWAI_TOOLS_ALLOWED_DIRS env var for extending allowed roots, the
fail-closed behavior when cwd is the filesystem root, and the
CREWAI_TOOLS_ALLOW_UNSAFE_PATHS escape hatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:45:36 +08:00
Rip&Tear
3bce3cceed fix: never default the path allow-list to the filesystem root
_get_allowed_roots defaulted its primary root to os.getcwd(). In a
container started without a WORKDIR, cwd is "/", and since "/" is a
parent of every absolute path the deny-by-default allow-list then
permitted the entire filesystem -- silently disabling confinement and
re-opening arbitrary LLM-controlled file read/write (the exact hole this
PR closes).

Distinguish an implicitly defaulted primary root (base_dir is None ->
os.getcwd()) from operator-provided roots (base_dir, allowed_dirs,
CREWAI_TOOLS_ALLOWED_DIRS). When the implicit cwd default resolves to
os.sep it is dropped; an explicit "/" is still honored as a deliberate
opt-in. If no usable root remains, raise a clear ValueError instead of
allowing everything.

Addresses the corridor-security review finding on #6248.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:22:29 +08:00
Rip&Tear
bdb763bfde Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-20 11:11:54 +08:00
Rip&Tear
5c9436d368 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-20 11:11:37 +08:00
Rip&Tear
4877828264 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-20 11:11:24 +08:00
Rip&Tear
685ea13c3b Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-20 11:11:01 +08:00
Rip&Tear
b70c74e17b Merge branch 'main' into fix/file-tools-path-allowlist 2026-06-20 11:00:47 +08:00
Rip&Tear
e0df891bdd fix: confine file tools to an allow-listed root to block path traversal
LLM/prompt-injection-controlled file paths could escape the working
directory. The RAG search tools and FileReadTool already routed through
validate_file_path, but FileWriterTool only checked that `filename` did
not escape the caller-supplied `directory` — and `directory` is itself
LLM-controlled, so an agent fed untrusted content could be steered into
writing anywhere on disk (e.g. ~/.ssh/authorized_keys).

- safe_path: replace the single base_dir cwd jail with a deny-by-default
  allow-list of roots, sourced from cwd + CREWAI_TOOLS_ALLOWED_DIRS +
  a caller-passed allowed_dirs. Backward compatible for existing callers.
- FileWriterTool: route the resolved write target through
  validate_file_path so writes are confined to an allow-listed root
  regardless of the directory argument.
- Tests: allow-list extension via env/param, deny-by-default, multi-root,
  and a regression test for the unbounded-directory write.

BREAKING: FileWriterTool no longer writes to arbitrary absolute
directories by default. Set CREWAI_TOOLS_ALLOWED_DIRS to permit
out-of-cwd writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 01:29:19 +08:00
72 changed files with 1135 additions and 2167 deletions

View File

@@ -134,21 +134,17 @@ def bedrock_host_matcher(r1: Request, r2: Request) -> bool: # type: ignore[no-a
)
def _patched_make_vcr_request(
httpx_request: Any, real_request_body: Any = None, **kwargs: Any
) -> Any:
def _patched_make_vcr_request(httpx_request: Any, **kwargs: Any) -> Any:
"""Patched version of VCR's _make_vcr_request that handles binary content.
The original implementation fails on binary request bodies (like file uploads)
because it assumes all content can be decoded as UTF-8.
"""
raw_body = real_request_body if real_request_body is not None else httpx_request.read()
body: Any = raw_body
if isinstance(raw_body, bytes):
try:
body = raw_body.decode("utf-8")
except UnicodeDecodeError:
body = base64.b64encode(raw_body).decode("ascii")
raw_body = httpx_request.read()
try:
body = raw_body.decode("utf-8")
except UnicodeDecodeError:
body = base64.b64encode(raw_body).decode("ascii")
uri = str(httpx_request.url)
headers = dict(httpx_request.headers)
return Request(httpx_request.method, uri, body, headers)

View File

@@ -551,35 +551,6 @@
}
]
},
{
"tab": "Platform API",
"icon": "code",
"groups": [
{
"group": "Overview",
"pages": [
"edge/api/v1/platform-api/introduction"
]
},
{
"group": "Reference",
"openapi": {
"source": "/edge/openapi/platform-v1.yaml",
"directory": "edge/api/v1/platform-api/reference"
}
},
{
"group": "Problems",
"pages": [
"edge/api/v1/problems",
"edge/api/v1/problems/bad_request",
"edge/api/v1/problems/not_found",
"edge/api/v1/problems/validation_error",
"edge/api/v1/problems/internal_error"
]
}
]
},
{
"tab": "Examples",
"icon": "code",
@@ -1104,35 +1075,6 @@
}
]
},
{
"tab": "Platform API",
"icon": "code",
"groups": [
{
"group": "Overview",
"pages": [
"v1.14.7/api/v1/platform-api/introduction"
]
},
{
"group": "Reference",
"openapi": {
"source": "/v1.14.7/openapi/platform-v1.yaml",
"directory": "v1.14.7/api/v1/platform-api/reference"
}
},
{
"group": "Problems",
"pages": [
"v1.14.7/api/v1/problems",
"v1.14.7/api/v1/problems/bad_request",
"v1.14.7/api/v1/problems/not_found",
"v1.14.7/api/v1/problems/validation_error",
"v1.14.7/api/v1/problems/internal_error"
]
}
]
},
{
"tab": "Examples",
"icon": "code",

View File

@@ -1,19 +0,0 @@
---
title: "Platform API"
description: "Build against the supported CrewAI Platform public API."
icon: "code"
mode: "wide"
---
# CrewAI Platform API
The Platform API is the supported public API for CrewAI Platform. Use it when
you need stable HTTP contracts for automation, integrations, and agent-facing
workflows.
The current public contract is `v1`. Endpoints live under `/api/v1` on the
CrewAI Platform app host:
```text
https://app.crewai.com/api/v1
```

View File

@@ -1,13 +0,0 @@
---
title: "Problems"
description: "Error responses returned by the CrewAI Platform API."
---
# Problems
When a request fails, the Platform API returns an `errors` array. Each item
includes a stable `code`, an HTTP `status`, and a `detail` message with
request-specific context.
Use the pages in this section to understand what each error code means and what
to change before retrying.

View File

@@ -1,17 +0,0 @@
---
code: bad_request
title: Bad request
status: 400
---
# Bad request
The request could not be processed because it was malformed or missing required request data.
## When It Happens
This usually means the request body, query string, headers, or required parameters are invalid before endpoint-specific validation can run.
## How To Fix
Review the endpoint contract, required parameters, request body shape, and content type before retrying.

View File

@@ -1,17 +0,0 @@
---
code: internal_error
title: Internal error
status: 500
---
# Internal error
An unexpected server-side failure prevented the request from completing.
## When It Happens
This means the platform encountered an unexpected condition while processing a valid request.
## How To Fix
Retry the request after a short delay. If the problem continues, contact support with the request details and timestamp.

View File

@@ -1,17 +0,0 @@
---
code: not_found
title: Not found
status: 404
---
# Not found
The requested resource does not exist or is not available at the requested path.
## When It Happens
This can happen when the URL is incorrect, the resource identifier does not exist, or the resource is not visible through the public API.
## How To Fix
Check the endpoint path and resource identifier, then retry with a resource that exists and is available to the request.

View File

@@ -1,17 +0,0 @@
---
code: validation_error
title: Validation error
status: 422
---
# Validation error
The request was understood, but one or more submitted values failed validation.
## When It Happens
This usually means a submitted field is missing, malformed, out of range, duplicated, or conflicts with another value.
## How To Fix
Inspect the `detail` message for the field-specific issue, update the submitted values, and retry the request.

View File

@@ -64,7 +64,7 @@ mode: "wide"
- تنفيذ أدوات تشغيل تعريف التدفق بدون كود Python
- دفع التغذية الراجعة البشرية من تعريف التدفق
- توصيل التكوين والاستمرارية من FlowDefinition إلى وقت التشغيل
- إضافة `crewai run --definition` للتدفقات التصريحية
- إضافة `crewai run --definition` التجريبية للتدفقات
- دعم تراجع نشر ZIP وتشغيل مشاريع الطاقم بتنسيق JSON
- تقديم الطواقم بتنسيق JSON أولاً

View File

@@ -959,7 +959,7 @@ source .venv/bin/activate
بعد تفعيل البيئة الافتراضية، يمكنك تشغيل التدفق بتنفيذ أحد الأوامر التالية:
```bash
crewai run
crewai flow kickoff
```
أو
@@ -1160,4 +1160,10 @@ crewai run
يكتشف هذا الأمر تلقائيًا ما إذا كان مشروعك تدفقًا (بناءً على إعداد `type = "flow"` في pyproject.toml الخاص بك) ويشغّله وفقًا لذلك. هذه هي الطريقة الموصى بها لتشغيل التدفقات من سطر الأوامر.
أمر `crewai flow kickoff` القديم deprecated. استخدم `crewai run` لكل من فرق Crew والتدفقات.
للتوافق مع الإصدارات السابقة، يمكنك أيضًا استخدام:
```shell
crewai flow kickoff
```
ومع ذلك، فإن أمر `crewai run` هو الطريقة المفضلة الآن لأنه يعمل لكل من فرق Crew والتدفقات.

View File

@@ -172,7 +172,7 @@ crewai install
## الخطوة 8: تشغيل Flow
```bash
crewai run
crewai flow kickoff
```
عند تشغيل هذا الأمر، ستشاهد Flow يعمل:

View File

@@ -64,7 +64,7 @@ mode: "wide"
- Implement Flow definition run tools without Python code
- Drive human feedback from the flow definition
- Wire config and persistence from FlowDefinition into the runtime
- Add `crewai run --definition` for declarative flows
- Add experimental `crewai run --definition` for flows
- Support ZIP deployment fallback and JSON crew project env runs
- Introduce JSON first crews

View File

@@ -956,13 +956,13 @@ Once all of the dependencies are installed, you need to activate the virtual env
source .venv/bin/activate
```
After activating the virtual environment, you can run the flow with the CrewAI CLI:
After activating the virtual environment, you can run the flow by executing one of the following commands:
```bash
crewai run
crewai flow kickoff
```
You can also run the project script directly:
or
```bash
uv run kickoff
@@ -1160,4 +1160,10 @@ crewai run
This command automatically detects if your project is a flow (based on the `type = "flow"` setting in your pyproject.toml) and runs it accordingly. This is the recommended way to run flows from the command line.
The legacy `crewai flow kickoff` command is deprecated. Use `crewai run` for both crews and flows.
For backward compatibility, you can also use:
```shell
crewai flow kickoff
```
However, the `crewai run` command is now the preferred method as it works for both crews and flows.

View File

@@ -395,7 +395,7 @@ crewai install
Now it's time to see your flow in action! Run it using the CrewAI CLI:
```bash
crewai run
crewai flow kickoff
```
When you run this command, you'll see your flow spring to life:

View File

@@ -42,6 +42,25 @@ print(result)
- `content`: The content to write into the file.
- `directory` (optional): The path to the directory where the file will be created. Defaults to the current directory (`.`). If the directory does not exist, it will be created.
## Path confinement
Because `filename` and `directory` may be supplied at runtime by an agent acting on untrusted content, `FileWriterTool` confines writes to an **allow-listed set of root directories**. The resolved target (after expanding symlinks and `..`) must fall inside one of these roots or the write is rejected — a `directory` argument pointing outside them (e.g. `~/.ssh`, `/etc`) no longer grants write access.
The allow-list is, by default, the current working directory. You can extend it for deployments that legitimately write elsewhere:
- `CREWAI_TOOLS_ALLOWED_DIRS` — one or more additional root directories, separated by the OS path separator (`:` on Linux/macOS, `;` on Windows).
```shell
# Allow writes under /data and /workspace in addition to the cwd
export CREWAI_TOOLS_ALLOWED_DIRS="/data:/workspace"
```
<Warning>
If the process runs with its working directory set to the filesystem root (`/`) — common in containers started without a `WORKDIR` — the tool will **not** fall back to allow-listing the entire filesystem. Writes fail with a `ValueError` until you set `CREWAI_TOOLS_ALLOWED_DIRS` to an explicit directory. Set a `WORKDIR` (or the env var) in such deployments.
</Warning>
The `CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true` escape hatch disables path validation entirely. It is intended only for trusted local development and should not be set in any environment that runs agent-generated or otherwise untrusted instructions.
## Conclusion
By integrating the `FileWriterTool` into your crews, the agents can reliably write content to files across different operating systems.

View File

@@ -64,7 +64,7 @@ mode: "wide"
- Python 코드 없이 Flow 정의 실행 도구 구현
- Flow 정의에서 인간 피드백 유도
- FlowDefinition의 구성 및 지속성을 런타임에 연결
- 선언적 흐름을 위한 `crewai run --definition` 추가
- 흐름을 위한 실험적 `crewai run --definition` 추가
- ZIP 배포 대체 및 JSON 크루 프로젝트 환경 실행 지원
- JSON 우선 크루 도입

View File

@@ -951,7 +951,7 @@ source .venv/bin/activate
가상 환경을 활성화한 후, 아래 명령어 중 하나를 실행하여 플로우를 실행할 수 있습니다:
```bash
crewai run
crewai flow kickoff
```
또는
@@ -1054,4 +1054,10 @@ crewai run
이 명령어는 프로젝트가 pyproject.toml의 `type = "flow"` 설정을 기반으로 flow인지 자동으로 감지하여 해당 방식으로 실행합니다. 명령줄에서 flow를 실행하는 권장 방법입니다.
레거시 `crewai flow kickoff` 명령어는 deprecated되었습니다. crew와 flow 모두 `crewai run`을 사용하세요.
하위 호환성을 위해 다음 명령어도 사용할 수 있습니다:
```shell
crewai flow kickoff
```
하지만 `crewai run` 명령어가 이제 crew와 flow 모두에 작동하므로 더욱 선호되는 방법입니다.

View File

@@ -393,7 +393,7 @@ crewai install
이제 여러분의 flow가 실제로 작동하는 모습을 볼 차례입니다! CrewAI CLI를 사용하여 flow를 실행하세요:
```bash
crewai run
crewai flow kickoff
```
이 명령어를 실행하면 flow가 다음과 같이 작동하는 것을 확인할 수 있습니다:

View File

@@ -1,115 +0,0 @@
openapi: 3.0.1
info:
title: CrewAI Platform API
version: v1
description: Supported public API for CrewAI Platform.
servers:
- url: https://app.crewai.com
description: CrewAI Platform
security: []
tags:
- name: Status
description: Platform health and API availability.
paths:
/api/v1/status:
get:
summary: Check API status
operationId: getStatus
tags:
- Status
responses:
'200':
description: OK
content:
application/json:
examples:
success:
value:
data:
status: ok
summary: API is available
schema:
type: object
required:
- data
additionalProperties: false
properties:
data:
type: object
required:
- status
additionalProperties: false
properties:
status:
type: string
enum:
- ok
example: ok
components:
schemas:
SuccessEnvelope:
type: object
required:
- data
additionalProperties: false
properties:
data:
description: Endpoint-specific response payload.
ErrorEnvelope:
type: object
required:
- errors
additionalProperties: false
properties:
errors:
type: array
minItems: 1
items:
$ref: '#/components/schemas/Error'
Error:
type: object
description: Public API error object.
required:
- type
- code
- title
- status
- detail
additionalProperties: false
properties:
type:
type: string
format: uri
enum:
- https://docs.crewai.com/api/v1/problems/bad_request
- https://docs.crewai.com/api/v1/problems/internal_error
- https://docs.crewai.com/api/v1/problems/not_found
- https://docs.crewai.com/api/v1/problems/validation_error
example: https://docs.crewai.com/api/v1/problems/bad_request
code:
type: string
enum:
- bad_request
- internal_error
- not_found
- validation_error
example: bad_request
title:
type: string
enum:
- Bad request
- Internal error
- Not found
- Validation error
example: Bad request
status:
type: integer
enum:
- 400
- 404
- 422
- 500
example: 400
detail:
type: string
example: The request is invalid.

View File

@@ -64,7 +64,7 @@ mode: "wide"
- Implementar ferramentas de execução de definição de fluxo sem código Python
- Conduzir feedback humano a partir da definição de fluxo
- Conectar configuração e persistência do FlowDefinition ao tempo de execução
- Adicionar `crewai run --definition` para fluxos declarativos
- Adicionar `crewai run --definition` experimental para fluxos
- Suportar fallback de implantação ZIP e execuções de projeto de equipe em JSON
- Introduzir equipes em JSON primeiro

View File

@@ -948,7 +948,7 @@ source .venv/bin/activate
Com o ambiente ativado, execute o flow usando um dos comandos:
```bash
crewai run
crewai flow kickoff
```
ou
@@ -1052,4 +1052,10 @@ crewai run
O comando detecta automaticamente se seu projeto é um flow (com base na configuração `type = "flow"` no pyproject.toml) e executa conforme o esperado. Esse é o método recomendado para executar flows pelo terminal.
O comando legado `crewai flow kickoff` está deprecated. Use `crewai run` para crews e flows.
Por compatibilidade retroativa, também é possível usar:
```shell
crewai flow kickoff
```
No entanto, o comando `crewai run` é agora o preferido, pois funciona tanto para crews quanto para flows.

View File

@@ -393,7 +393,7 @@ crewai install
Agora é hora de ver seu flow em ação! Execute-o usando a CLI do CrewAI:
```bash
crewai run
crewai flow kickoff
```
Quando você rodar esse comando, verá seu flow ganhando vida:

View File

@@ -1,19 +0,0 @@
---
title: "Platform API"
description: "Build against the supported CrewAI Platform public API."
icon: "code"
mode: "wide"
---
# CrewAI Platform API
The Platform API is the supported public API for CrewAI Platform. Use it when
you need stable HTTP contracts for automation, integrations, and agent-facing
workflows.
The current public contract is `v1`. Endpoints live under `/api/v1` on the
CrewAI Platform app host:
```text
https://app.crewai.com/api/v1
```

View File

@@ -1,13 +0,0 @@
---
title: "Problems"
description: "Error responses returned by the CrewAI Platform API."
---
# Problems
When a request fails, the Platform API returns an `errors` array. Each item
includes a stable `code`, an HTTP `status`, and a `detail` message with
request-specific context.
Use the pages in this section to understand what each error code means and what
to change before retrying.

View File

@@ -1,17 +0,0 @@
---
code: bad_request
title: Bad request
status: 400
---
# Bad request
The request could not be processed because it was malformed or missing required request data.
## When It Happens
This usually means the request body, query string, headers, or required parameters are invalid before endpoint-specific validation can run.
## How To Fix
Review the endpoint contract, required parameters, request body shape, and content type before retrying.

View File

@@ -1,17 +0,0 @@
---
code: internal_error
title: Internal error
status: 500
---
# Internal error
An unexpected server-side failure prevented the request from completing.
## When It Happens
This means the platform encountered an unexpected condition while processing a valid request.
## How To Fix
Retry the request after a short delay. If the problem continues, contact support with the request details and timestamp.

View File

@@ -1,17 +0,0 @@
---
code: not_found
title: Not found
status: 404
---
# Not found
The requested resource does not exist or is not available at the requested path.
## When It Happens
This can happen when the URL is incorrect, the resource identifier does not exist, or the resource is not visible through the public API.
## How To Fix
Check the endpoint path and resource identifier, then retry with a resource that exists and is available to the request.

View File

@@ -1,17 +0,0 @@
---
code: validation_error
title: Validation error
status: 422
---
# Validation error
The request was understood, but one or more submitted values failed validation.
## When It Happens
This usually means a submitted field is missing, malformed, out of range, duplicated, or conflicts with another value.
## How To Fix
Inspect the `detail` message for the field-specific issue, update the submitted values, and retry the request.

View File

@@ -1,115 +0,0 @@
openapi: 3.0.1
info:
title: CrewAI Platform API
version: v1
description: Supported public API for CrewAI Platform.
servers:
- url: https://app.crewai.com
description: CrewAI Platform
security: []
tags:
- name: Status
description: Platform health and API availability.
paths:
/api/v1/status:
get:
summary: Check API status
operationId: getStatus
tags:
- Status
responses:
'200':
description: OK
content:
application/json:
examples:
success:
value:
data:
status: ok
summary: API is available
schema:
type: object
required:
- data
additionalProperties: false
properties:
data:
type: object
required:
- status
additionalProperties: false
properties:
status:
type: string
enum:
- ok
example: ok
components:
schemas:
SuccessEnvelope:
type: object
required:
- data
additionalProperties: false
properties:
data:
description: Endpoint-specific response payload.
ErrorEnvelope:
type: object
required:
- errors
additionalProperties: false
properties:
errors:
type: array
minItems: 1
items:
$ref: '#/components/schemas/Error'
Error:
type: object
description: Public API error object.
required:
- type
- code
- title
- status
- detail
additionalProperties: false
properties:
type:
type: string
format: uri
enum:
- https://docs.crewai.com/api/v1/problems/bad_request
- https://docs.crewai.com/api/v1/problems/internal_error
- https://docs.crewai.com/api/v1/problems/not_found
- https://docs.crewai.com/api/v1/problems/validation_error
example: https://docs.crewai.com/api/v1/problems/bad_request
code:
type: string
enum:
- bad_request
- internal_error
- not_found
- validation_error
example: bad_request
title:
type: string
enum:
- Bad request
- Internal error
- Not found
- Validation error
example: Bad request
status:
type: integer
enum:
- 400
- 404
- 422
- 500
example: 400
detail:
type: string
example: The request is invalid.

View File

@@ -40,6 +40,14 @@ def replay_task_command(*args: Any, **kwargs: Any) -> Any:
return _replay_task_command(*args, **kwargs)
def run_flow_definition(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_flow_definition import (
run_flow_definition as _run_flow_definition,
)
return _run_flow_definition(*args, **kwargs)
def run_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_crew import run_crew as _run_crew
@@ -147,18 +155,12 @@ def uv(uv_args: tuple[str, ...]) -> None:
is_flag=True,
help="Use classic Python/YAML project structure instead of JSON",
)
@click.option(
"--declarative",
is_flag=True,
help="Create a declarative Flow project instead of a Python Flow project",
)
def create(
type: str | None,
name: str | None,
provider: str | None,
skip_provider: bool = False,
classic: bool = False,
declarative: bool = False,
) -> None:
"""Create a new crew, or flow."""
dmn_mode = is_dmn_mode_enabled()
@@ -192,8 +194,6 @@ def create(
if dmn_mode:
skip_provider = True
if type == "crew":
if declarative:
raise click.UsageError("--declarative can only be used with flow projects")
if classic:
from crewai_cli.create_crew import create_crew
@@ -205,7 +205,7 @@ def create(
elif type == "flow":
from crewai_cli.create_flow import create_flow
create_flow(name, declarative=declarative)
create_flow(name)
else:
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
@@ -468,7 +468,7 @@ def memory(
type=str,
default=None,
help=(
"Crew-only: path to a trained-agents pickle (produced by `crewai train -f`). "
"Path to a trained-agents pickle (produced by `crewai train -f`). "
"When set, agents load suggestions from this file instead of the "
"default trained_agents_data.pkl. Equivalent to setting "
"CREWAI_TRAINED_AGENTS_FILE."
@@ -512,13 +512,16 @@ def install(context: click.Context) -> None:
"--definition",
type=str,
default=None,
help="Flow-only: path to a declarative flow definition.",
help=(
"Experimental: path to a Flow Definition YAML/JSON file, "
"or an inline YAML/JSON string."
),
)
@click.option(
"--inputs",
type=str,
default=None,
help='Flow-only: JSON object passed to the declarative flow, e.g. \'{"topic":"AI"}\'.',
help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
)
def run(
trained_agents_file: str | None,
@@ -528,14 +531,16 @@ def run(
"""Run the Crew or Flow."""
if inputs is not None and definition is None:
raise click.UsageError("--inputs requires --definition")
if trained_agents_file is not None and definition is not None:
raise click.UsageError("--filename can only be used when running crews")
run_crew(
trained_agents_file=trained_agents_file,
definition=definition,
inputs=inputs,
)
if definition is not None:
click.secho(
"Warning: `crewai run --definition` is experimental and may change without notice.",
fg="yellow",
)
run_flow_definition(definition=definition, inputs=inputs)
return
run_crew(trained_agents_file=trained_agents_file)
@crewai.command()
@@ -790,11 +795,10 @@ def flow() -> None:
@flow.command(name="kickoff")
def flow_run() -> None:
"""Kickoff the Flow."""
click.secho(
"The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead.",
fg="yellow",
)
run_crew(trained_agents_file=None, definition=None, inputs=None)
from crewai_cli.kickoff_flow import kickoff_flow
click.echo("Running the Flow")
kickoff_flow()
@flow.command(name="plot")

View File

@@ -5,10 +5,7 @@ import click
from crewai_core.telemetry import Telemetry
DECLARATIVE_FLOW_FOLDERS = ("crews", "tools", "knowledge", "skills")
def create_flow(name: str, *, declarative: bool = False) -> None:
def create_flow(name: str) -> None:
"""Create a new flow."""
folder_name = name.replace(" ", "_").replace("-", "_").lower()
class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "")
@@ -23,17 +20,6 @@ def create_flow(name: str, *, declarative: bool = False) -> None:
telemetry = Telemetry()
telemetry.flow_creation_span(class_name)
if declarative:
_create_declarative_flow(name, class_name, folder_name, project_root)
else:
_create_python_flow(name, class_name, folder_name, project_root)
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
def _create_python_flow(
name: str, class_name: str, folder_name: str, project_root: Path
) -> None:
(project_root / "src" / folder_name).mkdir(parents=True)
(project_root / "src" / folder_name / "crews").mkdir(parents=True)
(project_root / "src" / folder_name / "tools").mkdir(parents=True)
@@ -106,41 +92,4 @@ def _create_python_flow(
fg="yellow",
)
def _create_declarative_flow(
name: str, class_name: str, folder_name: str, project_root: Path
) -> None:
project_root.mkdir(parents=True)
package_root = project_root / "src" / folder_name
package_root.mkdir(parents=True)
for folder in DECLARATIVE_FLOW_FOLDERS:
(package_root / folder).mkdir()
package_dir = Path(__file__).parent
templates_dir = package_dir / "templates" / "declarative_flow"
agents_md_src = package_dir / "templates" / "AGENTS.md"
if agents_md_src.exists():
shutil.copy2(agents_md_src, project_root / "AGENTS.md")
for src_file in templates_dir.rglob("*"):
if not src_file.is_file():
continue
relative_path = src_file.relative_to(templates_dir)
dst_file = (
project_root / relative_path
if relative_path.name in {".gitignore", "README.md", "pyproject.toml"}
else package_root / relative_path
)
dst_file.parent.mkdir(parents=True, exist_ok=True)
content = src_file.read_text(encoding="utf-8")
content = content.replace("{{name}}", name)
content = content.replace("{{flow_name}}", class_name)
content = content.replace("{{folder_name}}", folder_name)
dst_file.write_text(content, encoding="utf-8")
(project_root / ".env").write_text("OPENAI_API_KEY=YOUR_API_KEY", encoding="utf-8")
(package_root / "__init__.py").write_text("", encoding="utf-8")
for folder in DECLARATIVE_FLOW_FOLDERS:
(package_root / folder / ".gitkeep").write_text("", encoding="utf-8")
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)

View File

@@ -680,7 +680,7 @@ def _default_agents_and_tasks(
]
crew_settings = {
"process": "sequential",
"memory": True,
"memory": False,
"inputs": {},
}
return agents, tasks, crew_settings

View File

@@ -1,11 +1,15 @@
from __future__ import annotations
from pathlib import Path
import re
import shutil
import tempfile
from typing import Any
import zipfile
from crewai_cli import git
from crewai_cli.deploy.validate import normalize_package_name
from crewai_cli.utils import parse_toml
_EXCLUDED_DIRS = {
@@ -34,6 +38,8 @@ _EXCLUDED_SUFFIXES = {
".pyc",
".pyo",
}
_SCRIPT_KEY_PATTERN = re.compile(r"^\s*(?P<key>[A-Za-z0-9_.-]+|\"[^\"]+\"|'[^']+')\s*=")
_SECTION_PATTERN = re.compile(r"^\s*\[[^\]]+\]\s*(?:#.*)?$")
def create_project_zip(
@@ -137,7 +143,267 @@ def _stage_project(root: Path, files: list[Path]) -> Path:
destination = staging_root / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
if _is_json_crew_project(staging_root):
_add_json_crew_deploy_wrapper(staging_root)
except Exception:
shutil.rmtree(staging_root, ignore_errors=True)
raise
return staging_root
def _is_json_crew_project(root: Path) -> bool:
"""Return True for JSON crew projects that need a Python deploy wrapper."""
if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()):
return False
project = _read_pyproject(root)
tool_config = project.get("tool") or {}
crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
declared_type = (
crewai_config.get("type") if isinstance(crewai_config, dict) else None
)
if declared_type == "flow":
return False
package_name = _package_name(root)
if package_name is None:
raise ValueError(
"Could not derive a valid Python package name from [project].name."
)
return not (root / "src" / package_name / "crew.py").is_file()
def _read_pyproject(root: Path) -> dict[str, Any]:
"""Read pyproject.toml, returning an empty mapping on missing or invalid data."""
pyproject_path = root / "pyproject.toml"
if not pyproject_path.is_file():
return {}
try:
pyproject = parse_toml(pyproject_path.read_text())
except Exception:
return {}
return pyproject if isinstance(pyproject, dict) else {}
def _package_name(root: Path) -> str | None:
"""Return the normalized Python package name for the project."""
project = _read_pyproject(root).get("project")
if not isinstance(project, dict):
return None
name = project.get("name")
if not isinstance(name, str) or not name.strip():
return None
package_name = normalize_package_name(name)
return package_name or None
def _class_name(package_name: str) -> str:
"""Return the generated wrapper class name for a package."""
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part]
class_name = "".join(part[:1].upper() + part[1:] for part in parts)
if not class_name:
return "JsonCrew"
if class_name[0].isdigit():
return f"Crew{class_name}"
return class_name
def _add_json_crew_deploy_wrapper(root: Path) -> None:
"""Add Python wrapper files required to deploy a JSON crew project."""
package_name = _package_name(root)
if package_name is None:
raise ValueError(
"Could not derive a valid Python package name from [project].name."
)
package_dir = root / "src" / package_name
config_dir = package_dir / "config"
config_dir.mkdir(parents=True, exist_ok=True)
class_name = _class_name(package_name)
crew_filename = "crew.jsonc" if (root / "crew.jsonc").is_file() else "crew.json"
(package_dir / "__init__.py").write_text("", encoding="utf-8")
(config_dir / "agents.yaml").write_text("{}\n", encoding="utf-8")
(config_dir / "tasks.yaml").write_text("{}\n", encoding="utf-8")
(package_dir / "crew.py").write_text(
_json_crew_py(class_name, crew_filename),
encoding="utf-8",
)
(package_dir / "main.py").write_text(
_json_main_py(package_name, class_name),
encoding="utf-8",
)
_ensure_project_scripts(root, package_name)
def _json_crew_py(class_name: str, crew_filename: str) -> str:
"""Render the generated crew.py module for a JSON crew."""
return f'''from pathlib import Path
from crewai import Crew
from crewai.project import CrewBase, crew
from crewai.project.crew_loader import load_crew
def _crew_path() -> Path:
return Path(__file__).resolve().parents[2] / "{crew_filename}"
@CrewBase
class {class_name}:
"""Compatibility wrapper for a JSON-defined CrewAI project."""
@crew
def crew(self) -> Crew:
crew_instance, default_inputs = load_crew(_crew_path())
self.default_inputs = default_inputs
return crew_instance
'''
def _json_main_py(package_name: str, class_name: str) -> str:
"""Render the generated main.py entrypoints for a JSON crew."""
return f"""#!/usr/bin/env python
import json
import sys
from {package_name}.crew import {class_name}
def _load():
wrapper = {class_name}()
crew = wrapper.crew()
return crew, getattr(wrapper, "default_inputs", {{}})
def run():
crew, inputs = _load()
return crew.kickoff(inputs=inputs)
def train():
crew, inputs = _load()
return crew.train(
n_iterations=int(sys.argv[1]),
filename=sys.argv[2],
inputs=inputs,
)
def replay():
crew, _ = _load()
return crew.replay(task_id=sys.argv[1])
def test():
crew, inputs = _load()
return crew.test(
n_iterations=int(sys.argv[1]),
eval_llm=sys.argv[2],
inputs=inputs,
)
def run_with_trigger():
if len(sys.argv) < 2:
raise ValueError("No trigger payload provided.")
crew, inputs = _load()
trigger_payload = json.loads(sys.argv[1])
return crew.kickoff(
inputs={{**inputs, "crewai_trigger_payload": trigger_payload}}
)
"""
def _ensure_project_scripts(root: Path, package_name: str) -> None:
"""Ensure generated wrappers have project script entrypoints."""
pyproject_path = root / "pyproject.toml"
if not pyproject_path.is_file():
return
content = pyproject_path.read_text(encoding="utf-8")
entries = _project_script_entries(package_name)
pyproject_path.write_text(
_update_project_scripts(content, entries),
encoding="utf-8",
)
def _project_script_entries(package_name: str) -> dict[str, str]:
"""Return script entrypoints required by the generated JSON wrapper."""
return {
package_name: f"{package_name}.main:run",
"run_crew": f"{package_name}.main:run",
"train": f"{package_name}.main:train",
"replay": f"{package_name}.main:replay",
"test": f"{package_name}.main:test",
"run_with_trigger": f"{package_name}.main:run_with_trigger",
}
def _update_project_scripts(content: str, entries: dict[str, str]) -> str:
"""Add or replace generated script entries in pyproject.toml content."""
lines = content.rstrip().splitlines()
header_index = _project_scripts_header_index(lines)
if header_index is None:
return content.rstrip() + _project_scripts_block(entries)
end_index = _section_end_index(lines, header_index + 1)
seen: set[str] = set()
for index in range(header_index + 1, end_index):
key = _script_key(lines[index])
if key in entries:
lines[index] = _script_line(key, entries[key])
seen.add(key)
missing_lines = [
_script_line(key, value) for key, value in entries.items() if key not in seen
]
lines[end_index:end_index] = missing_lines
return "\n".join(lines).rstrip() + "\n"
def _project_scripts_header_index(lines: list[str]) -> int | None:
"""Return the line index of the project scripts table, if present."""
for index, line in enumerate(lines):
if line.strip() == "[project.scripts]":
return index
return None
def _section_end_index(lines: list[str], start_index: int) -> int:
"""Return the exclusive end index for a TOML table section."""
for index in range(start_index, len(lines)):
if _SECTION_PATTERN.match(lines[index]):
return index
return len(lines)
def _script_key(line: str) -> str | None:
"""Return the script key for a pyproject script line."""
match = _SCRIPT_KEY_PATTERN.match(line)
if not match:
return None
key = match.group("key")
if key.startswith(("'", '"')) and key.endswith(("'", '"')):
return key[1:-1]
return key
def _script_line(key: str, value: str) -> str:
"""Render a project script TOML entry."""
return f'{key} = "{value}"'
def _project_scripts_block(entries: dict[str, str]) -> str:
"""Render a project scripts TOML table."""
lines = ["", "", "[project.scripts]"]
lines.extend(_script_line(key, value) for key, value in entries.items())
return "\n".join(lines) + "\n"

View File

@@ -212,16 +212,8 @@ class DeployValidator:
if crew_path is None:
return self.results
agents_dir = self.project_root / "agents"
self._check_pyproject()
self._check_lockfile()
agents_dir_ok = self._check_json_agents_dir(agents_dir)
project = None
try:
if agents_dir_ok:
project = validate_crew_project(crew_path, agents_dir)
project = validate_crew_project(crew_path, self.project_root / "agents")
except JSONProjectValidationError as e:
self._add(
Severity.ERROR,
@@ -240,27 +232,15 @@ class DeployValidator:
)
return self.results
if project is not None:
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
agents_dir = self.project_root / "agents"
self._check_pyproject()
self._check_lockfile()
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
self._check_version_vs_lockfile()
return self.results
def _check_json_agents_dir(self, agents_dir: Path) -> bool:
if agents_dir.is_dir():
return True
self._add(
Severity.ERROR,
"missing_agents_dir",
"Cannot find agents/ directory",
detail=(
"JSON crew projects load agent definitions from "
f"{agents_dir.relative_to(self.project_root)}/*.jsonc or *.json."
),
hint="Create agents/ and add one JSON or JSONC file per agent.",
)
return False
def _check_env_vars_json(
self, crew_path: Path, agents_dir: Path, agent_names: list[str]
) -> None:

View File

@@ -0,0 +1,23 @@
import subprocess
import click
def kickoff_flow() -> None:
"""
Kickoff the flow by running a command in the UV environment.
"""
command = ["uv", "run", "kickoff"]
try:
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
if result.stderr:
click.echo(result.stderr, err=True)
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while running the flow: {e}", err=True)
click.echo(e.output, err=True)
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)

View File

@@ -5,27 +5,19 @@ import click
def plot_flow() -> None:
"""
Plot the flow from declarative config or the Python UV entrypoint.
Plot the flow by running a command in the UV environment.
"""
from crewai_cli.run_declarative_flow import (
configured_project_declarative_flow,
plot_declarative_flow_in_project_env,
)
command = ["uv", "run", "plot"]
if definition := configured_project_declarative_flow():
plot_declarative_flow_in_project_env(definition)
else:
command = ["uv", "run", "plot"]
try:
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
try:
subprocess.run( # noqa: S603
command, capture_output=False, text=True, check=True
)
if result.stderr:
click.echo(result.stderr, err=True)
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while plotting the flow: {e}", err=True)
raise SystemExit(1) from e
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while plotting the flow: {e}", err=True)
click.echo(e.output, err=True)
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)
raise SystemExit(1) from e
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from enum import Enum
import os
from pathlib import Path
import re
@@ -26,6 +27,11 @@ if TYPE_CHECKING:
from crewai_cli.crew_run_tui import CrewRunApp
class CrewType(Enum):
STANDARD = "standard"
FLOW = "flow"
# Must accept the same names as the kickoff interpolation pattern in
# crewai.utilities.string_utils (_VARIABLE_PATTERN), including hyphens —
# otherwise placeholders are interpolated at runtime but never prompted for.
@@ -531,11 +537,7 @@ def _print_post_tui_summary(app: CrewRunApp) -> None:
)
def run_crew(
trained_agents_file: str | None = None,
definition: str | None = None,
inputs: str | None = None,
) -> None:
def run_crew(trained_agents_file: str | None = None) -> None:
"""Run the crew or flow.
Args:
@@ -543,88 +545,15 @@ def run_crew(
by ``crewai train -f``. When set, exported as
``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this
file instead of the default ``trained_agents_data.pkl``.
definition: Optional path to a declarative Flow definition.
inputs: Optional JSON object passed to a declarative Flow.
"""
if inputs is not None and definition is None:
raise click.UsageError("--inputs requires --definition")
if definition is not None:
_run_explicit_declarative_flow(
definition=definition,
inputs=inputs,
trained_agents_file=trained_agents_file,
)
return
# JSON crew projects take precedence
if _has_json_crew():
_run_json_crew_in_project_env(trained_agents_file=trained_agents_file)
return
pyproject_data = read_toml()
_warn_if_old_poetry_project(pyproject_data)
project_type = _get_project_type(pyproject_data)
if project_type == "flow":
_run_flow_project(
pyproject_data=pyproject_data,
trained_agents_file=trained_agents_file,
)
return
_run_classic_crew_project(
pyproject_data=pyproject_data,
trained_agents_file=trained_agents_file,
)
def _run_explicit_declarative_flow(
definition: str, inputs: str | None, trained_agents_file: str | None
) -> None:
if trained_agents_file is not None:
raise click.UsageError("--filename can only be used when running crews")
from crewai_cli.run_declarative_flow import run_declarative_flow
run_declarative_flow(definition=definition, inputs=inputs)
def _run_flow_project(
pyproject_data: dict[str, Any], trained_agents_file: str | None
) -> None:
if trained_agents_file is not None:
raise click.UsageError("--filename can only be used when running crews")
from crewai_cli.run_declarative_flow import (
configured_project_declarative_flow,
run_declarative_flow_in_project_env,
)
if definition := configured_project_declarative_flow(pyproject_data):
run_declarative_flow_in_project_env(definition=definition)
return
_execute_uv_script("kickoff", entity_type="flow")
def _run_classic_crew_project(
pyproject_data: dict[str, Any], trained_agents_file: str | None
) -> None:
_execute_uv_script(
"run_crew",
entity_type="crew",
trained_agents_file=trained_agents_file,
)
def _get_project_type(pyproject_data: dict[str, Any]) -> str | None:
project_type = pyproject_data.get("tool", {}).get("crewai", {}).get("type")
return project_type if isinstance(project_type, str) else None
def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None:
crewai_version = get_crewai_version()
min_required_version = "0.71.0"
pyproject_data = read_toml()
if pyproject_data.get("tool", {}).get("poetry") and (
version.parse(crewai_version) < version.parse(min_required_version)
@@ -635,22 +564,25 @@ def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None:
fg="red",
)
is_flow = pyproject_data.get("tool", {}).get("crewai", {}).get("type") == "flow"
crew_type = CrewType.FLOW if is_flow else CrewType.STANDARD
def _execute_uv_script(
script_name: str,
*,
entity_type: str,
trained_agents_file: str | None = None,
click.echo(f"Running the {'Flow' if is_flow else 'Crew'}")
execute_command(crew_type, trained_agents_file=trained_agents_file)
def execute_command(
crew_type: CrewType, trained_agents_file: str | None = None
) -> None:
"""Execute a project script through uv.
"""Execute the appropriate command based on crew type.
Args:
script_name: The project script to run.
entity_type: The user-facing entity being run.
crew_type: The type of crew to run.
trained_agents_file: Optional trained-agents pickle path forwarded to
the subprocess via the ``CREWAI_TRAINED_AGENTS_FILE`` env var.
"""
command = ["uv", "run", script_name]
command = ["uv", "run", "kickoff" if crew_type == CrewType.FLOW else "run_crew"]
env = build_env_with_all_tool_credentials()
if trained_agents_file:
@@ -660,20 +592,21 @@ def _execute_uv_script(
subprocess.run(command, capture_output=False, text=True, check=True, env=env) # noqa: S603
except subprocess.CalledProcessError as e:
_handle_run_error(e, entity_type)
handle_error(e, crew_type)
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)
def _handle_run_error(error: subprocess.CalledProcessError, entity_type: str) -> None:
def handle_error(error: subprocess.CalledProcessError, crew_type: CrewType) -> None:
"""
Handle subprocess errors with appropriate messaging.
Args:
error: The subprocess error that occurred
entity_type: The type of entity that was being run
crew_type: The type of crew that was being run
"""
entity_type = "flow" if crew_type == CrewType.FLOW else "crew"
click.echo(f"An error occurred while running the {entity_type}: {error}", err=True)
if error.output:

View File

@@ -1,212 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
import subprocess
from typing import Any
import click
from crewai_cli.utils import build_env_with_all_tool_credentials
def run_declarative_flow_in_project_env(
definition: str, inputs: str | None = None
) -> None:
"""Run a declarative flow inside the project's Python environment."""
if is_declarative_flow_project_env() or not _has_project_file():
run_declarative_flow(definition=definition, inputs=inputs)
return
if inputs is not None:
raise click.UsageError("--inputs is only supported with --definition")
_execute_declarative_flow_command(["uv", "run", "crewai", "run"])
def plot_declarative_flow_in_project_env(definition: str) -> None:
"""Plot a declarative flow inside the project's Python environment."""
if is_declarative_flow_project_env() or not _has_project_file():
plot_declarative_flow(definition=definition)
return
_execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
"""Run a declarative flow from a definition path."""
parsed_inputs = _parse_inputs(inputs)
try:
flow = load_declarative_flow(definition)
result = flow.kickoff(inputs=parsed_inputs)
except Exception as exc:
click.echo(
f"An error occurred while running the declarative flow: {exc}", err=True
)
raise SystemExit(1) from exc
click.echo(_format_result(result))
def plot_declarative_flow(definition: str) -> None:
"""Plot a declarative flow from a definition path."""
try:
flow = load_declarative_flow(definition)
flow.plot()
except Exception as exc:
click.echo(
f"An error occurred while plotting the declarative flow: {exc}", err=True
)
raise SystemExit(1) from exc
def load_declarative_flow(definition: str) -> Any:
"""Load a declarative Flow instance from a definition path."""
try:
from crewai.flow.flow import Flow
from crewai.flow.flow_definition import FlowDefinition
except ImportError as exc:
click.echo(
"Running declarative flows requires the full crewai package.",
err=True,
)
raise SystemExit(1) from exc
definition_path = Path(definition).expanduser()
definition_source = _read_declarative_flow_source(definition_path, definition)
flow_definition = _parse_declarative_flow(
FlowDefinition,
definition_source,
source_path=definition_path,
)
return Flow.from_definition(flow_definition)
def configured_project_declarative_flow(
pyproject_data: dict[str, Any] | None = None,
) -> str | None:
"""Return the configured declarative flow source for flow projects."""
if pyproject_data is None:
try:
from crewai_cli.utils import read_toml
pyproject_data = read_toml()
except Exception:
return None
crewai_config = pyproject_data.get("tool", {}).get("crewai", {})
if crewai_config.get("type") != "flow":
return None
definition = crewai_config.get("definition")
if not isinstance(definition, str):
return None
return definition.strip() or None
def _execute_declarative_flow_command(command: list[str]) -> None:
env = build_env_with_all_tool_credentials()
try:
subprocess.run( # noqa: S603
command,
capture_output=False,
text=True,
check=True,
env=env,
)
except subprocess.CalledProcessError as e:
raise SystemExit(e.returncode) from e
except Exception as e:
click.echo(
f"An unexpected error occurred while running the declarative flow: {e}",
err=True,
)
raise SystemExit(1) from e
def is_declarative_flow_project_env() -> bool:
import os
return os.environ.get("UV_RUN_RECURSION_DEPTH") is not None
def _has_project_file(project_root: Path | None = None) -> bool:
root = project_root or Path.cwd()
return (root / "pyproject.toml").is_file()
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
if inputs is None:
return None
try:
parsed = json.loads(inputs)
except json.JSONDecodeError as exc:
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
raise SystemExit(1) from exc
if not isinstance(parsed, dict):
click.echo("Invalid --inputs JSON: expected an object.", err=True)
raise SystemExit(1)
return parsed
def _read_declarative_flow_source(path: Path, definition: str) -> str:
try:
if path.is_file():
source = _read_declarative_flow_file(path)
elif path.exists():
click.echo(
f"Invalid --definition path: {definition} is not a file.", err=True
)
raise SystemExit(1)
else:
click.echo(
f"Invalid --definition path: {definition} does not exist.", err=True
)
raise SystemExit(1)
except OSError as exc:
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
return source
def _read_declarative_flow_file(path: Path) -> str:
try:
source = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
click.echo(
f"Unable to read --definition path {path}: {exc}",
err=True,
)
raise SystemExit(1) from exc
return source
def _parse_declarative_flow(
flow_definition_cls: type[Any], source: str, *, source_path: Path
) -> Any:
if _looks_like_json(source):
return flow_definition_cls.from_json(source, source_path=source_path)
return flow_definition_cls.from_yaml(source, source_path=source_path)
def _looks_like_json(source: str) -> bool:
stripped = source.lstrip()
return stripped.startswith("{")
def _format_result(result: Any) -> str:
raw_result = getattr(result, "raw", result)
if isinstance(raw_result, str):
return raw_result
try:
return json.dumps(raw_result, default=str)
except TypeError:
return str(raw_result)

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import click
def run_flow_definition(definition: str, inputs: str | None = None) -> None:
"""Run a flow from a Flow Definition YAML/JSON string or file path."""
try:
from crewai.flow.flow import Flow
from crewai.flow.flow_definition import FlowDefinition
except ImportError as exc:
click.echo(
"Running flows from definitions requires the full crewai package.",
err=True,
)
raise SystemExit(1) from exc
parsed_inputs = _parse_inputs(inputs)
definition_source = _read_definition_source(definition)
try:
flow_definition = _parse_flow_definition(FlowDefinition, definition_source)
flow = Flow.from_definition(flow_definition)
result = flow.kickoff(inputs=parsed_inputs)
except Exception as exc:
click.echo(
f"An error occurred while running the flow definition: {exc}", err=True
)
raise SystemExit(1) from exc
click.echo(_format_result(result))
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
if inputs is None:
return None
try:
parsed = json.loads(inputs)
except json.JSONDecodeError as exc:
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
raise SystemExit(1) from exc
if not isinstance(parsed, dict):
click.echo("Invalid --inputs JSON: expected an object.", err=True)
raise SystemExit(1)
return parsed
def _read_definition_source(definition: str) -> str:
path = Path(definition).expanduser()
try:
is_file = path.is_file()
except OSError as exc:
if _looks_like_inline_definition(definition):
return definition
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
if is_file:
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
click.echo(
f"Unable to read --definition path {path}: {exc}",
err=True,
)
raise SystemExit(1) from exc
try:
if path.exists():
click.echo(
f"Invalid --definition path: {definition} is not a file.", err=True
)
raise SystemExit(1)
except OSError as exc:
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
return definition
def _looks_like_inline_definition(definition: str) -> bool:
stripped = definition.lstrip()
return "\n" in definition or stripped.startswith(("{", "---")) or ":" in stripped
def _parse_flow_definition(flow_definition_cls: type[Any], source: str) -> Any:
if _looks_like_json(source):
return flow_definition_cls.from_json(source)
return flow_definition_cls.from_yaml(source)
def _looks_like_json(source: str) -> bool:
stripped = source.lstrip()
return stripped.startswith("{")
def _format_result(result: Any) -> str:
raw_result = getattr(result, "raw", result)
if isinstance(raw_result, str):
return raw_result
try:
return json.dumps(raw_result, default=str)
except TypeError:
return str(raw_result)

View File

@@ -62,7 +62,7 @@ crewai create flow <name> --skip_provider # New flow project
# Running
crewai run # Run crew or flow (auto-detects from pyproject.toml)
crewai flow kickoff # Deprecated compatibility alias for crewai run
crewai flow kickoff # Legacy flow execution
# Testing & training
crewai test # Test crew (default: 2 iterations, gpt-4o-mini)

View File

@@ -1,5 +0,0 @@
.env
.venv/
__pycache__/
.crewai/
output/

View File

@@ -1,17 +0,0 @@
# {{name}} Flow
This project defines a declarative CrewAI Flow in `src/{{folder_name}}/flow.yaml`.
## Install
```bash
crewai install
```
## Run
```bash
crewai run
```
Edit the declarative flow definition at `src/{{folder_name}}/flow.yaml` to change the flow. Add reusable crews under `src/{{folder_name}}/crews/`, custom Python tools under `src/{{folder_name}}/tools/`, and shared knowledge files under `src/{{folder_name}}/knowledge/`.

View File

@@ -1,15 +0,0 @@
schema: crewai.flow/v1
name: {{flow_name}}
description: A declarative CrewAI Flow.
state:
type: dict
default:
topic: AI agents
methods:
start:
start: true
do:
call: expression
expr: state.topic

View File

@@ -1,20 +0,0 @@
[project]
name = "{{folder_name}}"
version = "0.1.0"
description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.8a2"
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/{{folder_name}}"]
[tool.crewai]
type = "flow"
definition = "src/{{folder_name}}/flow.yaml"

View File

@@ -132,7 +132,7 @@ def test_create_project_zip_excludes_symlinked_files(tmp_path: Path):
assert names == {"pyproject.toml"}
def test_create_project_zip_preserves_json_project_shape(tmp_path: Path):
def test_create_project_zip_adds_json_project_wrapper(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -157,6 +157,8 @@ type = "crew"
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
crew_py = archive.read("src/json_crew/crew.py").decode()
main_py = archive.read("src/json_crew/main.py").decode()
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
@@ -164,50 +166,18 @@ type = "crew"
assert "uv.lock" not in names
assert "crew.jsonc" in names
assert "agents/researcher.jsonc" in names
assert all(not name.startswith("src/") for name in names)
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
assert "src/json_crew/__init__.py" in names
assert "src/json_crew/crew.py" in names
assert "src/json_crew/main.py" in names
assert "src/json_crew/config/agents.yaml" in names
assert "src/json_crew/config/tasks.yaml" in names
assert "load_crew(_crew_path())" in crew_py
assert "JsonCrew" in crew_py
assert "from json_crew.crew import JsonCrew" in main_py
assert "run_crew = \"json_crew.main:run\"" in pyproject
def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
dependencies = ["crewai[tools]==1.14.8a1"]
[tool.crewai]
type = "crew"
""".strip()
+ "\n"
)
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "agents").mkdir()
(tmp_path / "agents" / "foo.jsonc").write_text("{}\n")
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert names == {
"agents/foo.jsonc",
"crew.jsonc",
"pyproject.toml",
"uv.lock",
}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_does_not_rewrite_json_project_scripts(tmp_path: Path):
def test_create_project_zip_updates_existing_json_project_scripts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -233,10 +203,14 @@ type = "crew"
finally:
archive_path.unlink(missing_ok=True)
assert 'json_crew = "old.module:run"' in pyproject
assert 'run_crew = "old.module:run"' in pyproject
assert 'json_crew = "json_crew.main:run"' in pyproject
assert 'run_crew = "json_crew.main:run"' in pyproject
assert 'train = "json_crew.main:train"' in pyproject
assert 'replay = "json_crew.main:replay"' in pyproject
assert 'test = "json_crew.main:test"' in pyproject
assert 'run_with_trigger = "json_crew.main:run_with_trigger"' in pyproject
assert 'custom = "custom.module:main"' in pyproject
assert pyproject.count("[project.scripts]") == 1
assert "old.module:run" not in pyproject
assert "[tool.crewai]" in pyproject
@@ -247,7 +221,7 @@ type = "crew"
'[tool]\ncrewai = "invalid"\n',
],
)
def test_create_project_zip_preserves_json_project_with_malformed_tool_config(
def test_create_project_zip_adds_json_wrapper_for_malformed_tool_config(
tmp_path: Path, tool_config: str
):
(tmp_path / "pyproject.toml").write_text(
@@ -270,13 +244,12 @@ version = "0.1.0"
finally:
archive_path.unlink(missing_ok=True)
assert names == {"crew.jsonc", "pyproject.toml"}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
assert "src/json_crew/crew.py" in names
assert "src/json_crew/main.py" in names
assert "run_crew = \"json_crew.main:run\"" in pyproject
def test_create_project_zip_accepts_json_project_without_package_name(tmp_path: Path):
def test_create_project_zip_rejects_empty_normalized_package_name(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -290,15 +263,8 @@ type = "crew"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("invalid", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert names == {"crew.jsonc", "pyproject.toml"}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
with pytest.raises(
ValueError,
match=r"Could not derive a valid Python package name",
):
create_project_zip("invalid", project_dir=tmp_path)

View File

@@ -200,41 +200,6 @@ def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
assert "runtime-only" in finding.detail
def test_json_crew_requires_agents_dir_without_classic_errors(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path)
for path in (tmp_path / "agents").iterdir():
path.unlink()
(tmp_path / "agents").rmdir()
v = DeployValidator(project_root=tmp_path)
v.run()
codes = _codes(v)
assert "missing_agents_dir" in codes
assert "missing_src_dir" not in codes
assert "missing_crew_py" not in codes
assert "missing_agents_yaml" not in codes
assert "missing_tasks_yaml" not in codes
def test_json_crew_reports_project_metadata_before_invalid_json(
tmp_path: Path,
) -> None:
_scaffold_json_crew(tmp_path)
(tmp_path / "pyproject.toml").unlink()
(tmp_path / "uv.lock").unlink()
(tmp_path / "crew.jsonc").write_text('{"agents": ["researcher"], "tasks": []}\n')
v = DeployValidator(project_root=tmp_path)
v.run()
codes = _codes(v)
assert "missing_pyproject" in codes
assert "missing_lockfile" in codes
assert "invalid_crew_json" in codes
assert "missing_src_dir" not in codes
def test_missing_pyproject_errors(tmp_path: Path) -> None:
v = _run_without_import_check(tmp_path)
assert "missing_pyproject" in _codes(v)

View File

@@ -12,7 +12,6 @@ from crewai_cli.cli import (
deploy_remove,
deply_status,
flow_add_crew,
flow_run,
login,
reset_memories,
run,
@@ -127,75 +126,38 @@ def test_run_uses_project_runner_by_default(run_crew, runner):
result = runner.invoke(run)
assert result.exit_code == 0
run_crew.assert_called_once_with(
trained_agents_file=None,
definition=None,
inputs=None,
)
run_crew.assert_called_once_with(trained_agents_file=None)
assert "experimental" not in result.output.lower()
@mock.patch("crewai_cli.cli.run_crew")
def test_run_with_definition_uses_project_runner(run_crew, runner):
@mock.patch("crewai_cli.cli.run_flow_definition")
def test_run_with_definition_uses_definition_runner(run_flow_definition, runner):
result = runner.invoke(
run,
["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'],
)
assert result.exit_code == 0
run_crew.assert_called_once_with(
trained_agents_file=None,
definition="flow.yaml",
inputs='{"topic":"AI"}',
assert (
"Warning: `crewai run --definition` is experimental and may change without notice."
in result.output
)
run_flow_definition.assert_called_once_with(
definition="flow.yaml", inputs='{"topic":"AI"}'
)
@mock.patch("crewai_cli.cli.run_crew")
def test_run_rejects_inputs_without_definition(run_crew, runner):
@mock.patch("crewai_cli.cli.run_flow_definition")
def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, runner):
result = runner.invoke(run, ["--inputs", '{"topic":"AI"}'])
assert result.exit_code == 2
assert "Error: --inputs requires --definition" in result.output
run_flow_definition.assert_not_called()
run_crew.assert_not_called()
@mock.patch("crewai_cli.cli.run_crew")
def test_run_rejects_filename_with_definition(run_crew, runner):
result = runner.invoke(run, ["--definition", "flow.yaml", "--filename", "x.pkl"])
assert result.exit_code == 2
assert "Error: --filename can only be used when running crews" in result.output
run_crew.assert_not_called()
@mock.patch("crewai_cli.cli.run_crew")
def test_run_passes_filename_to_project_runner(run_crew, runner):
result = runner.invoke(run, ["--filename", "trained.pkl"])
assert result.exit_code == 0
run_crew.assert_called_once_with(
trained_agents_file="trained.pkl",
definition=None,
inputs=None,
)
@mock.patch("crewai_cli.cli.run_crew")
def test_flow_kickoff_is_deprecated_and_uses_run_path(run_crew, runner):
result = runner.invoke(flow_run)
assert result.exit_code == 0
run_crew.assert_called_once_with(
trained_agents_file=None,
definition=None,
inputs=None,
)
assert (
"The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead."
in result.output
)
@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"})
@@ -204,23 +166,6 @@ def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner
create_json_crew.assert_called_once_with("DMN Crew", None, True)
@mock.patch("crewai_cli.create_flow.create_flow")
def test_create_flow_declarative_uses_declarative_scaffold(create_flow, runner):
result = runner.invoke(create, ["flow", "My Flow", "--declarative"])
assert result.exit_code == 0
create_flow.assert_called_once_with("My Flow", declarative=True)
@mock.patch("crewai_cli.create_json_crew.create_json_crew")
def test_create_crew_rejects_declarative_flag(create_json_crew, runner):
result = runner.invoke(create, ["crew", "My Crew", "--declarative"])
assert result.exit_code == 2
assert "--declarative can only be used with flow projects" in result.output
create_json_crew.assert_not_called()
def test_create_requires_type_in_dmn_mode(runner):
result = runner.invoke(create, env={"CREWAI_DMN": "True"})

View File

@@ -712,26 +712,8 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
default_llm="openai/gpt-5.5",
)
assert (tmp_path / "json_crew" / "crew.jsonc").exists()
assert not (tmp_path / "json_crew" / "src").exists()
assert not (tmp_path / "json_crew" / "tests").exists()
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
generated_paths = {
path.relative_to(tmp_path / "json_crew").as_posix()
for path in (tmp_path / "json_crew").rglob("*")
if path.is_file()
}
assert not any(
path.endswith("/crew.py") or path == "crew.py" for path in generated_paths
)
assert not any(
path.endswith("/agents.yaml") or path == "agents.yaml"
for path in generated_paths
)
assert not any(
path.endswith("/tasks.yaml") or path == "tasks.yaml"
for path in generated_paths
)
assert not any(path.startswith("src/") for path in generated_paths)
pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text())
dependency = pyproject["project"]["dependencies"][0]
@@ -867,7 +849,7 @@ def test_json_create_dmn_mode_uses_non_interactive_defaults(tmp_path, monkeypatc
crew_template = (project_root / "crew.jsonc").read_text()
agent_template = (project_root / "agents" / "researcher.jsonc").read_text()
assert '"memory": true' in crew_template
assert '"memory": false' in crew_template
assert '"description": "Research current AI trends and write a concise summary."' in (
crew_template
)

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
from pathlib import Path
from click.testing import CliRunner
from pytest import MonkeyPatch
import tomli
from crewai_cli.cli import crewai
from crewai_cli.create_flow import create_flow
def test_create_flow_declarative_project_can_run(
tmp_path: Path, monkeypatch: MonkeyPatch
):
monkeypatch.chdir(tmp_path)
create_flow("Research Flow", declarative=True)
project_root = tmp_path / "research_flow"
assert project_root.is_dir()
pyproject = tomli.loads(
(project_root / "pyproject.toml").read_text(encoding="utf-8")
)
assert pyproject["project"]["name"] == "research_flow"
assert pyproject["project"]["requires-python"]
assert pyproject["project"]["dependencies"]
assert (project_root / pyproject["tool"]["crewai"]["definition"]).is_file()
monkeypatch.chdir(project_root)
result = CliRunner().invoke(crewai, ["run"], env={"UV_RUN_RECURSION_DEPTH": "1"})
assert result.exit_code == 0
assert "Running the Flow" not in result.output
assert "AI agents" in result.output

View File

@@ -1,117 +0,0 @@
from __future__ import annotations
from pathlib import Path
import subprocess
import pytest
from click.testing import CliRunner
from crewai_cli.cli import flow_run
import crewai_cli.plot_flow as plot_flow_module
FLOW_YAML = """\
schema: crewai.flow/v1
name: TestFlow
config:
suppress_flow_events: true
methods:
begin:
start: true
do:
call: expression
expr: "'AI'"
"""
def _write_flow_project(project_root: Path) -> None:
(project_root / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8")
(project_root / "pyproject.toml").write_text(
'[project]\nname = "demo"\n\n'
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
encoding="utf-8",
)
def test_flow_kickoff_runs_configured_declarative_definition(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_write_flow_project(tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
result = CliRunner().invoke(flow_run)
assert result.exit_code == 0
assert (
"The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead."
in result.output
)
assert "AI\n" in result.output
assert "Running the Flow" not in result.output
def test_plot_flow_runs_configured_declarative_definition(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_write_flow_project(tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
plot_flow_module.plot_flow()
def test_flow_kickoff_delegates_to_run_crew(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = []
monkeypatch.setattr(
"crewai_cli.cli.run_crew",
lambda **kwargs: calls.append(kwargs),
)
result = CliRunner().invoke(flow_run)
assert result.exit_code == 0
assert calls == [
{"trained_agents_file": None, "definition": None, "inputs": None},
]
def test_plot_flow_keeps_python_entrypoint_without_definition(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
subprocess_calls = []
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(
subprocess,
"run",
lambda command, **kwargs: subprocess_calls.append((command, kwargs)),
)
plot_flow_module.plot_flow()
assert subprocess_calls == [
(
["uv", "run", "plot"],
{"capture_output": False, "text": True, "check": True},
)
]
def test_configured_project_declarative_flow(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n',
encoding="utf-8",
)
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
assert configured_project_declarative_flow() == "flow.yaml"

View File

@@ -568,131 +568,3 @@ def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path):
(tmp_path / "crew.jsonc").write_text("{}")
assert run_crew_module._has_json_crew() is True
def test_run_crew_rejects_inputs_without_definition():
with pytest.raises(click.UsageError) as exc_info:
run_crew_module.run_crew(inputs='{"topic":"AI"}')
assert "--inputs requires --definition" in exc_info.value.message
def test_run_crew_rejects_filename_with_explicit_definition():
with pytest.raises(click.UsageError) as exc_info:
run_crew_module.run_crew(
trained_agents_file="trained.pkl",
definition="flow.yaml",
)
assert "--filename can only be used when running crews" in exc_info.value.message
def test_run_crew_runs_explicit_declarative_definition(monkeypatch, capsys):
calls = []
def fake_run_declarative_flow(definition: str, inputs: str | None = None):
calls.append((definition, inputs))
monkeypatch.setattr(
"crewai_cli.run_declarative_flow.run_declarative_flow",
fake_run_declarative_flow,
)
run_crew_module.run_crew(definition="flow.yaml", inputs='{"topic":"AI"}')
captured = capsys.readouterr()
assert "experimental" not in captured.out.lower()
assert calls == [("flow.yaml", '{"topic":"AI"}')]
def test_run_crew_runs_classic_crew_project(monkeypatch, capsys):
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {"tool": {"crewai": {"type": "crew"}}},
)
monkeypatch.setattr(
run_crew_module,
"_execute_uv_script",
lambda script_name, **kwargs: calls.append((script_name, kwargs)),
)
run_crew_module.run_crew(trained_agents_file="trained.pkl")
assert capsys.readouterr().out == ""
assert calls == [
(
"run_crew",
{"entity_type": "crew", "trained_agents_file": "trained.pkl"},
)
]
def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {"tool": {"crewai": {"type": "flow"}}},
)
monkeypatch.setattr(
run_crew_module,
"_execute_uv_script",
lambda script_name, **kwargs: calls.append((script_name, kwargs)),
)
run_crew_module.run_crew()
assert capsys.readouterr().out == ""
assert calls == [("kickoff", {"entity_type": "flow"})]
def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {"tool": {"crewai": {"type": "flow"}}},
)
with pytest.raises(click.UsageError) as exc_info:
run_crew_module.run_crew(trained_agents_file="trained.pkl")
assert "--filename can only be used when running crews" in exc_info.value.message
def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {
"tool": {
"crewai": {
"type": "flow",
"definition": "flow.yaml",
}
}
},
)
monkeypatch.setattr(
"crewai_cli.run_declarative_flow.run_declarative_flow_in_project_env",
lambda definition, inputs=None: calls.append((definition, inputs)),
)
monkeypatch.setattr(
run_crew_module,
"_execute_uv_script",
lambda *_args, **_kwargs: pytest.fail("declarative flows must not run kickoff"),
)
run_crew_module.run_crew()
assert capsys.readouterr().out == ""
assert calls == [("flow.yaml", None)]

View File

@@ -1,111 +0,0 @@
from __future__ import annotations
from pathlib import Path
import pytest
import crewai_cli.run_declarative_flow as run_declarative_flow_module
FLOW_YAML = """\
schema: crewai.flow/v1
name: TestFlow
config:
suppress_flow_events: true
methods:
begin:
start: true
do:
call: expression
expr: state.topic
"""
def test_run_declarative_flow_reads_definition_file(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(FLOW_YAML, encoding="utf-8")
run_declarative_flow_module.run_declarative_flow(
str(definition_path), '{"topic":"AI"}'
)
assert capsys.readouterr().out == "AI\n"
def test_run_declarative_flow_rejects_non_object_inputs(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(FLOW_YAML, encoding="utf-8")
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow(
str(definition_path), '["not", "an", "object"]'
)
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
def test_run_declarative_flow_reports_missing_file(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow("missing-flow.yaml")
assert (
"Invalid --definition path: missing-flow.yaml does not exist."
in capsys.readouterr().err
)
def test_run_declarative_flow_in_project_env_uses_uv(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
subprocess_calls = []
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
monkeypatch.setattr(
run_declarative_flow_module,
"build_env_with_all_tool_credentials",
lambda: {"EXISTING": "value"},
)
monkeypatch.setattr(
run_declarative_flow_module.subprocess,
"run",
lambda command, **kwargs: subprocess_calls.append((command, kwargs)),
)
run_declarative_flow_module.run_declarative_flow_in_project_env("flow.yaml")
assert subprocess_calls == [
(
["uv", "run", "crewai", "run"],
{
"capture_output": False,
"text": True,
"check": True,
"env": {"EXISTING": "value"},
},
)
]
def test_run_declarative_flow_in_process_inside_uv(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8")
run_declarative_flow_module.run_declarative_flow_in_project_env(
"flow.yaml", '{"topic":"AI"}'
)
assert capsys.readouterr().out == "AI\n"

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
import json
import sys
import types
import pytest
import yaml
from crewai_cli.run_flow_definition import run_flow_definition
class _FakeFlow:
def __init__(self, definition):
self.definition = definition
def kickoff(self, inputs=None):
return {
"flow": self.definition["name"],
"inputs": inputs or {},
}
class _FakeFlowFactory:
@classmethod
def from_definition(cls, definition):
return _FakeFlow(definition)
class _FakeFlowDefinition:
@classmethod
def from_yaml(cls, source):
return yaml.safe_load(source)
@classmethod
def from_json(cls, source):
return json.loads(source)
@pytest.fixture
def fake_flow_runtime(monkeypatch):
crewai_module = types.ModuleType("crewai")
flow_package = types.ModuleType("crewai.flow")
flow_module = types.ModuleType("crewai.flow.flow")
flow_definition_module = types.ModuleType("crewai.flow.flow_definition")
flow_module.Flow = _FakeFlowFactory
flow_definition_module.FlowDefinition = _FakeFlowDefinition
monkeypatch.setitem(sys.modules, "crewai", crewai_module)
monkeypatch.setitem(sys.modules, "crewai.flow", flow_package)
monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module)
monkeypatch.setitem(
sys.modules, "crewai.flow.flow_definition", flow_definition_module
)
def _captured_json(capsys):
return json.loads(capsys.readouterr().out)
def test_run_flow_definition_reads_definition_file(
tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
run_flow_definition(str(definition_path), '{"topic":"AI"}')
assert _captured_json(capsys) == {
"flow": "TestFlow",
"inputs": {"topic": "AI"},
}
@pytest.mark.parametrize(
("definition_source", "expected_flow_name"),
[
pytest.param(
"schema: crewai.flow/v1\nname: InlineFlow\n",
"InlineFlow",
id="inline-yaml",
),
pytest.param(
'{"schema":"crewai.flow/v1","name":"InlineJsonFlow"}',
"InlineJsonFlow",
id="inline-json",
),
pytest.param(
'{"schema":"crewai.flow/v1","name":"' + ("JsonFlow" * 500) + '"}',
"JsonFlow" * 500,
id="large-inline-json",
),
],
)
def test_run_flow_definition_accepts_inline_definitions(
definition_source, expected_flow_name, capsys, fake_flow_runtime
):
run_flow_definition(definition_source)
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
@pytest.mark.parametrize(
("filename", "definition_source", "expected_flow_name"),
[
pytest.param(
"flow.yaml",
"schema: crewai.flow/v1\nname: YamlFileFlow\n",
"YamlFileFlow",
id="yaml-file",
),
pytest.param(
"flow.json",
'{"schema":"crewai.flow/v1","name":"JsonFlow"}',
"JsonFlow",
id="json-file",
),
],
)
def test_run_flow_definition_accepts_definition_files(
filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / filename
definition_path.write_text(definition_source)
run_flow_definition(str(definition_path))
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
def test_run_flow_definition_rejects_non_object_inputs(fake_flow_runtime, capsys):
with pytest.raises(SystemExit):
run_flow_definition("name: TestFlow", '["not", "an", "object"]')
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
def test_run_flow_definition_reports_unreadable_file(
monkeypatch, tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
def raise_permission_error(self, *args, **kwargs):
raise PermissionError("no access")
monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error)
with pytest.raises(SystemExit):
run_flow_definition(str(definition_path))
err = capsys.readouterr().err
assert "Unable to read --definition path" in err
assert str(definition_path) in err
assert "no access" in err

View File

@@ -20,6 +20,82 @@ from urllib.parse import urlparse
logger = logging.getLogger(__name__)
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
_ALLOWED_DIRS_ENV = "CREWAI_TOOLS_ALLOWED_DIRS"
def _get_allowed_roots(
base_dir: str | None = None,
allowed_dirs: list[str] | None = None,
) -> list[str]:
"""Build the deny-by-default set of allowed root directories.
Roots are drawn from, in order:
1. ``base_dir`` (defaults to the current working directory),
2. the ``CREWAI_TOOLS_ALLOWED_DIRS`` environment variable, split on
``os.pathsep``,
3. the caller-supplied ``allowed_dirs`` list.
Every root is resolved with :func:`os.path.realpath` so a symlinked root
is compared by its real location. Empty entries are ignored and duplicates
are collapsed while preserving order. The first element is always the
primary root used to resolve relative candidate paths.
The filesystem root (``os.sep``, e.g. ``"/"``) is never accepted as an
*implicitly defaulted* root. When ``base_dir`` is not supplied and the
current working directory is ``/`` -- common in containers started without
a ``WORKDIR`` -- defaulting to it would make every absolute path "within"
the allow-list and disable confinement entirely. In that case the cwd
default is dropped; an operator who genuinely wants the whole filesystem
must opt in explicitly via ``base_dir``, ``allowed_dirs``, or
``CREWAI_TOOLS_ALLOWED_DIRS``. If no usable root remains, a ``ValueError``
is raised rather than silently allowing everything.
"""
primary_explicit = base_dir is not None
primary = base_dir if base_dir is not None else os.getcwd()
# (root, is_explicit) -- explicit roots are operator-provided and may
# legitimately include the filesystem root as an opt-in.
raw_roots: list[tuple[str, bool]] = [(primary, primary_explicit)]
env_dirs = os.environ.get(_ALLOWED_DIRS_ENV, "")
if env_dirs:
raw_roots.extend((d, True) for d in env_dirs.split(os.pathsep) if d)
if allowed_dirs:
raw_roots.extend((d, True) for d in allowed_dirs if d)
resolved: list[str] = []
seen: set[str] = set()
for root, is_explicit in raw_roots:
real = os.path.realpath(root)
if real == os.sep and not is_explicit:
# Refuse to let an unconfigured cwd of "/" open the whole filesystem.
continue
if real not in seen:
seen.add(real)
resolved.append(real)
if not resolved:
raise ValueError(
"No safe allowed directory could be determined: the current working "
f"directory is the filesystem root ('{os.sep}'). Set "
f"{_ALLOWED_DIRS_ENV} to an explicit directory, pass "
f"base_dir/allowed_dirs, or set {_UNSAFE_PATHS_ENV}=true to bypass "
"path validation."
)
return resolved
def _is_within_root(resolved_path: str, resolved_root: str) -> bool:
"""Return True if *resolved_path* equals *resolved_root* or lives beneath it.
When ``resolved_root`` already ends with a separator (e.g. the filesystem
root ``"/"``), appending ``os.sep`` would double it, so the root is used
as-is for the prefix in that case.
"""
prefix = resolved_root if resolved_root.endswith(os.sep) else resolved_root + os.sep
return resolved_path == resolved_root or resolved_path.startswith(prefix)
def format_path_for_display(path: str, base_dir: str | None = None) -> str:
@@ -52,21 +128,32 @@ def _is_escape_hatch_enabled() -> bool:
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")
def validate_file_path(path: str, base_dir: str | None = None) -> str:
def validate_file_path(
path: str,
base_dir: str | None = None,
*,
allowed_dirs: list[str] | None = None,
) -> str:
"""Validate that a file path is safe to read.
Resolves symlinks and ``..`` components, then checks that the resolved
path falls within *base_dir* (defaults to the current working directory).
path falls within at least one allowed root directory. The allow-list is
built from *base_dir* (defaults to the current working directory), the
``CREWAI_TOOLS_ALLOWED_DIRS`` environment variable, and *allowed_dirs* —
see :func:`_get_allowed_roots`. Access is denied by default for anything
outside that set.
Args:
path: The file path to validate.
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
base_dir: Primary allowed root. Defaults to ``os.getcwd()`` and is
used to resolve relative ``path`` values.
allowed_dirs: Additional allowed root directories.
Returns:
The resolved, validated absolute path.
Raises:
ValueError: If the path escapes the allowed directory.
ValueError: If the path escapes every allowed directory.
"""
if _is_escape_hatch_enabled():
logger.warning(
@@ -76,30 +163,30 @@ def validate_file_path(path: str, base_dir: str | None = None) -> str:
)
return os.path.realpath(path)
if base_dir is None:
base_dir = os.getcwd()
allowed_roots = _get_allowed_roots(base_dir, allowed_dirs)
primary_root = allowed_roots[0]
resolved_base = os.path.realpath(base_dir)
resolved_path = os.path.realpath(
os.path.join(resolved_base, path) if not os.path.isabs(path) else path
path if os.path.isabs(path) else os.path.join(primary_root, path)
)
# Ensure the resolved path is within the base directory.
# When resolved_base already ends with a separator (e.g. the filesystem
# root "/"), appending os.sep would double it ("//"), so use the base
# as-is in that case.
prefix = resolved_base if resolved_base.endswith(os.sep) else resolved_base + os.sep
if not resolved_path.startswith(prefix) and resolved_path != resolved_base:
raise ValueError(
f"Path '{format_path_for_display(resolved_path, resolved_base)}' is "
f"outside the allowed directory. "
f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
)
if any(_is_within_root(resolved_path, root) for root in allowed_roots):
return resolved_path
return resolved_path
raise ValueError(
f"Path '{format_path_for_display(resolved_path, primary_root)}' is "
f"outside the allowed directories. "
f"Add the directory via {_ALLOWED_DIRS_ENV}, or set "
f"{_UNSAFE_PATHS_ENV}=true to bypass this check."
)
def validate_directory_path(path: str, base_dir: str | None = None) -> str:
def validate_directory_path(
path: str,
base_dir: str | None = None,
*,
allowed_dirs: list[str] | None = None,
) -> str:
"""Validate that a directory path is safe to read.
Same as :func:`validate_file_path` but also checks that the path
@@ -107,15 +194,16 @@ def validate_directory_path(path: str, base_dir: str | None = None) -> str:
Args:
path: The directory path to validate.
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
base_dir: Primary allowed root. Defaults to ``os.getcwd()``.
allowed_dirs: Additional allowed root directories.
Returns:
The resolved, validated absolute path.
Raises:
ValueError: If the path escapes the allowed directory or is not a directory.
ValueError: If the path escapes every allowed directory or is not a directory.
"""
validated = validate_file_path(path, base_dir)
validated = validate_file_path(path, base_dir, allowed_dirs=allowed_dirs)
if not os.path.isdir(validated):
raise ValueError(f"Path '{validated}' is not a directory.")
return validated

View File

@@ -1,5 +1,4 @@
import os
from pathlib import Path
from typing import Any
from crewai.tools import BaseTool
@@ -8,6 +7,7 @@ from pydantic import BaseModel
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
validate_file_path,
)
@@ -41,22 +41,27 @@ class FileWriterTool(BaseTool):
filepath = os.path.join(directory, filename)
# Prevent path traversal: the resolved path must be strictly inside
# filename, and symlink escapes regardless of how directory is set.
# is_relative_to() does a proper path-component comparison that is
# safe on case-insensitive filesystems and avoids the "// " edge case
# We also reject the case where filepath resolves to the directory
# itself, since that is not a valid file target.
real_directory = Path(directory).resolve()
real_filepath = Path(filepath).resolve()
display_filepath = format_path_for_display(
str(real_filepath), str(real_directory)
)
if (
not real_filepath.is_relative_to(real_directory)
or real_filepath == real_directory
):
return "Error: Invalid file path — the filename must not escape the target directory."
# Confine the resolved write target to an allow-listed root
# (cwd + CREWAI_TOOLS_ALLOWED_DIRS), NOT merely inside the
# caller-supplied `directory`. That value is itself untrusted when
# an LLM tool call chooses it, so checking containment against it
# would let an agent write anywhere (e.g. ~/.ssh/authorized_keys).
# validate_file_path resolves symlinks and ".." before checking.
try:
real_filepath = validate_file_path(filepath)
except ValueError as e:
return f"Error: {format_error_for_display(e)}"
real_directory = os.path.dirname(real_filepath)
display_filepath = format_path_for_display(real_filepath, real_directory)
# A target that resolves to an existing directory is not a valid
# file destination.
if os.path.isdir(real_filepath):
return (
"Error: Invalid file path — the target must be a file, "
"not a directory."
)
if kwargs.get("directory"):
os.makedirs(real_directory, exist_ok=True)

View File

@@ -17,12 +17,23 @@ def temp_env():
test_file = "test.txt"
test_content = "Hello, World!"
# FileWriterTool confines writes to an allow-listed root (cwd plus
# CREWAI_TOOLS_ALLOWED_DIRS). Explicitly permit this temp dir — this is the
# supported way for a developer to widen the write scope to an external
# directory, and lets the happy-path tests below write into it.
prev_allowed = os.environ.get("CREWAI_TOOLS_ALLOWED_DIRS")
os.environ["CREWAI_TOOLS_ALLOWED_DIRS"] = temp_dir
yield {
"temp_dir": temp_dir,
"test_file": test_file,
"test_content": test_content,
}
if prev_allowed is None:
os.environ.pop("CREWAI_TOOLS_ALLOWED_DIRS", None)
else:
os.environ["CREWAI_TOOLS_ALLOWED_DIRS"] = prev_allowed
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -196,3 +207,24 @@ def test_blocks_symlink_escape(tool, temp_env):
assert not os.path.exists(outside_file)
finally:
shutil.rmtree(outside_dir, ignore_errors=True)
def test_blocks_unbounded_directory_arg(tool, temp_env):
# The core fix: the `directory` argument is itself untrusted (LLM-chosen).
# A directory outside the allow-list must be rejected even when filename
# is benign — previously this let an agent write anywhere on disk
# (e.g. ~/.ssh/authorized_keys).
outside_dir = tempfile.mkdtemp() # NOT added to CREWAI_TOOLS_ALLOWED_DIRS
outside_file = os.path.join(outside_dir, "test.txt")
try:
result = tool._run(
filename="test.txt",
directory=outside_dir,
content="should not be written",
overwrite=True,
)
assert "Error" in result
assert not os.path.exists(outside_file)
finally:
shutil.rmtree(outside_dir, ignore_errors=True)

View File

@@ -32,12 +32,12 @@ class TestValidateFilePath:
def test_rejects_dotdot_traversal(self, tmp_path):
"""Reject ../ traversal that escapes base_dir."""
with pytest.raises(ValueError, match="outside the allowed directory"):
with pytest.raises(ValueError, match="outside the allowed director"):
validate_file_path("../../etc/passwd", str(tmp_path))
def test_rejects_absolute_path_outside_base(self, tmp_path):
"""Reject absolute path outside base_dir."""
with pytest.raises(ValueError, match="outside the allowed directory"):
with pytest.raises(ValueError, match="outside the allowed directories"):
validate_file_path("/etc/passwd", str(tmp_path))
def test_allows_absolute_path_inside_base(self, tmp_path):
@@ -50,7 +50,7 @@ class TestValidateFilePath:
"""Reject symlinks that point outside base_dir."""
link = tmp_path / "sneaky_link"
os.symlink("/etc/passwd", str(link))
with pytest.raises(ValueError, match="outside the allowed directory"):
with pytest.raises(ValueError, match="outside the allowed directories"):
validate_file_path("sneaky_link", str(tmp_path))
def test_defaults_to_cwd(self):
@@ -113,7 +113,7 @@ class TestValidateDirectoryPath:
validate_directory_path("file.txt", str(tmp_path))
def test_rejects_traversal(self, tmp_path):
with pytest.raises(ValueError, match="outside the allowed directory"):
with pytest.raises(ValueError, match="outside the allowed directories"):
validate_directory_path("../../", str(tmp_path))
@@ -191,3 +191,95 @@ class TestValidateUrl:
# file:// would normally be blocked
result = validate_url("file:///etc/passwd")
assert result == "file:///etc/passwd"
class TestAllowList:
"""Tests for the configurable deny-by-default allow-list of roots."""
def test_param_extends_allowed_roots(self, tmp_path):
"""A directory passed via allowed_dirs is permitted."""
extra = tmp_path / "extra"
extra.mkdir()
(extra / "data.txt").touch()
result = validate_file_path(
str(extra / "data.txt"),
base_dir=str(tmp_path / "base"),
allowed_dirs=[str(extra)],
)
assert result == str(extra / "data.txt")
def test_env_extends_allowed_roots(self, tmp_path, monkeypatch):
"""A directory listed in CREWAI_TOOLS_ALLOWED_DIRS is permitted."""
base = tmp_path / "base"
base.mkdir()
extra = tmp_path / "extra"
extra.mkdir()
(extra / "data.txt").touch()
monkeypatch.setenv("CREWAI_TOOLS_ALLOWED_DIRS", str(extra))
result = validate_file_path(str(extra / "data.txt"), base_dir=str(base))
assert result == str(extra / "data.txt")
def test_denied_without_allow_listing(self, tmp_path, monkeypatch):
"""The same external dir is rejected when not allow-listed."""
base = tmp_path / "base"
base.mkdir()
extra = tmp_path / "extra"
extra.mkdir()
(extra / "data.txt").touch()
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
with pytest.raises(ValueError, match="outside the allowed directories"):
validate_file_path(str(extra / "data.txt"), base_dir=str(base))
def test_multiple_env_roots(self, tmp_path, monkeypatch):
"""Multiple os.pathsep-separated roots are each honored."""
base = tmp_path / "base"
base.mkdir()
a = tmp_path / "a"
a.mkdir()
b = tmp_path / "b"
b.mkdir()
(a / "fa.txt").touch()
(b / "fb.txt").touch()
monkeypatch.setenv(
"CREWAI_TOOLS_ALLOWED_DIRS", os.pathsep.join([str(a), str(b)])
)
assert validate_file_path(str(a / "fa.txt"), base_dir=str(base)) == str(
a / "fa.txt"
)
assert validate_file_path(str(b / "fb.txt"), base_dir=str(base)) == str(
b / "fb.txt"
)
def test_cwd_root_default_is_not_an_allowed_root(self, tmp_path, monkeypatch):
"""An unconfigured cwd of '/' must not open the whole filesystem.
Regression for the deny-by-default allow-list silently defaulting to the
filesystem root in containers started without a WORKDIR.
"""
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
monkeypatch.delenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", raising=False)
monkeypatch.setattr(os, "getcwd", lambda: os.sep)
with pytest.raises(ValueError, match="filesystem root"):
validate_file_path("/etc/passwd")
def test_cwd_root_with_explicit_allowed_dirs_confines(
self, tmp_path, monkeypatch
):
"""With cwd '/', confinement falls back to the explicit allow-list."""
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
monkeypatch.setattr(os, "getcwd", lambda: os.sep)
(tmp_path / "data.txt").touch()
assert validate_file_path(
str(tmp_path / "data.txt"), allowed_dirs=[str(tmp_path)]
) == str(tmp_path / "data.txt")
with pytest.raises(ValueError, match="outside the allowed directories"):
validate_file_path("/etc/passwd", allowed_dirs=[str(tmp_path)])
def test_explicit_base_dir_root_is_opt_in(self, monkeypatch):
"""An explicit base_dir of '/' is honored as a deliberate opt-in."""
monkeypatch.delenv("CREWAI_TOOLS_ALLOWED_DIRS", raising=False)
monkeypatch.delenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", raising=False)
assert validate_file_path("/etc/passwd", base_dir=os.sep) == os.path.realpath(
"/etc/passwd"
)

View File

@@ -373,6 +373,9 @@ To enable tracing, do any one of these:
status: str = "running",
) -> None:
"""Show method status panel."""
if not self.verbose:
return
if status == "running":
style = "yellow"
panel_title = "🔄 Flow Method Running"

View File

@@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_merge_flow_method_definition,
_method_action,
_set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import ListenMethod
@@ -45,7 +45,7 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator:
def decorator(func: Callable[P, R]) -> ListenMethod[P, R]:
wrapper = ListenMethod(func)
_merge_flow_method_definition(
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),

View File

@@ -19,8 +19,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_merge_flow_method_definition,
_method_action,
_set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import RouterMethod
@@ -95,7 +95,7 @@ def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]:
def router(
condition: FlowTrigger | None = None,
condition: FlowTrigger,
*,
emit: Sequence[str] | str | None = None,
) -> FlowMethodDecorator:
@@ -107,7 +107,6 @@ def router(
Args:
condition: Specifies when the router should execute. Can be:
- None: no listen trigger, used when stacking with @start() or @listen()
- str: Route label or method name that triggers this router
- FlowCondition: Result from or_() or and_(), including nested conditions
- Flow method reference: A method whose completion triggers this router
@@ -147,17 +146,14 @@ def router(
else:
router_events = _get_router_return_events(func) or []
method_definition_kwargs: dict[str, Any] = {
"do": _method_action(func),
"router": True,
"emit": router_events or None,
}
if condition is not None:
method_definition_kwargs["listen"] = _to_definition_condition(condition)
_merge_flow_method_definition(
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(**method_definition_kwargs),
FlowMethodDefinition(
do=_method_action(func),
listen=_to_definition_condition(condition),
router=True,
emit=router_events or None,
),
)
return wrapper

View File

@@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_merge_flow_method_definition,
_method_action,
_set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import StartMethod
@@ -54,7 +54,7 @@ def start(
def decorator(func: Callable[P, R]) -> StartMethod[P, R]:
wrapper = StartMethod(func)
_merge_flow_method_definition(
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),

View File

@@ -106,25 +106,6 @@ def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None:
return None
def _merge_flow_method_definition(
wrapper: FlowMethod[P, R],
definition: FlowMethodDefinition,
) -> None:
existing = _get_flow_method_definition(wrapper)
if existing is None:
_set_flow_method_definition(wrapper, definition)
return
updates = {
field_name: getattr(definition, field_name)
for field_name in definition.model_fields_set
}
_set_flow_method_definition(
wrapper,
existing.model_copy(deep=True, update=updates),
)
def _is_json_serializable(value: Any) -> bool:
try:
json.dumps(value)

View File

@@ -1,6 +1,6 @@
"""Flow Definition: the serializable, declarative Flow contract.
"""Flow Structure: the serializable, language-agnostic Flow contract.
Defines :class:`FlowDefinition` and its sub-models — a static, declarative
Defines :class:`FlowDefinition` and its sub-models — a static, textual
(JSON/YAML) representation of a Flow: its methods, trigger conditions,
state, and configuration. It is independent of the Python authoring
layer that may have produced it and of the engine that runs it (see
@@ -11,7 +11,6 @@ from __future__ import annotations
import json
import logging
from pathlib import Path
import re
from typing import Annotated, Any, Literal, TypeAlias, cast
@@ -19,7 +18,6 @@ from pydantic import (
BaseModel,
ConfigDict,
Field,
PrivateAttr,
field_serializer,
model_validator,
)
@@ -408,19 +406,10 @@ class FlowCrewActionDefinition(BaseModel):
)
call: Literal["crew"] = Field(
description=(
"Action discriminator. Use crew to run an inline or referenced Crew "
"definition."
),
description="Action discriminator. Use crew to run an inline Crew definition.",
examples=["crew"],
)
from_declaration: str | None = Field(
default=None,
description="Path to a JSON/JSONC Crew declaration file or folder.",
examples=["crews/research_crew"],
)
with_: CrewDefinition | None = Field(
default=None,
with_: CrewDefinition = Field(
alias="with",
description="Inline Crew definition to load and execute for this action.",
examples=[
@@ -441,26 +430,10 @@ class FlowCrewActionDefinition(BaseModel):
"agent": "researcher",
}
],
"inputs": {"topic": "${state.topic}"},
}
],
)
inputs: dict[str, ExpressionData] | None = Field(
default=None,
description=(
"Input overrides passed to the Crew. String values are evaluated as CEL "
"only when the trimmed value starts with ${ and ends with }; all other "
"values are literal."
),
examples=[{"topic": "${state.topic}"}],
)
@model_validator(mode="after")
def _validate_crew_source(self) -> FlowCrewActionDefinition:
if bool(self.from_declaration) == (self.with_ is not None):
raise ValueError(
"crew action requires exactly one of from_declaration or with"
)
return self
class FlowAgentActionDefinition(BaseModel):
@@ -711,12 +684,10 @@ class FlowDefinition(BaseModel):
arbitrary_types_allowed=True,
)
_source_path: Path | None = PrivateAttr(default=None)
schema_: Literal["crewai.flow/v1"] = Field(
default="crewai.flow/v1",
alias="schema",
description="Declarative Flow schema identifier and version.",
description="Flow Definition schema identifier and version.",
examples=["crewai.flow/v1"],
)
name: str = Field(
@@ -793,45 +764,29 @@ class FlowDefinition(BaseModel):
allow_unicode=True,
)
@property
def source_path(self) -> Path | None:
"""Original definition file path, when loaded from a file."""
return self._source_path
@property
def source_dir(self) -> Path | None:
"""Directory used to resolve relative paths in the definition."""
if self._source_path is None:
return None
return self._source_path.parent
@classmethod
def from_dict(
cls, data: dict[str, Any], *, source_path: Path | None = None
) -> FlowDefinition:
def from_dict(cls, data: dict[str, Any]) -> FlowDefinition:
"""Load a definition from a dictionary."""
definition = cls.model_validate(data)
if source_path is not None:
definition._source_path = source_path.expanduser().resolve()
log_flow_definition_issues(definition)
return definition
@classmethod
def from_json(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition:
def from_json(cls, data: str) -> FlowDefinition:
"""Load a definition from JSON."""
return cls.from_dict(json.loads(data), source_path=source_path)
return cls.from_dict(json.loads(data))
@classmethod
def from_yaml(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition:
def from_yaml(cls, data: str) -> FlowDefinition:
"""Load a definition from YAML."""
loaded = yaml.safe_load(data) or {}
if not isinstance(loaded, dict):
raise ValueError("Flow definition YAML must contain a mapping")
return cls.from_dict(loaded, source_path=source_path)
return cls.from_dict(loaded)
@classmethod
def json_schema(cls) -> dict[str, Any]:
"""Return the JSON Schema for the declarative Flow contract."""
"""Return the JSON Schema for the Flow Definition contract."""
return cls.model_json_schema(by_alias=True)
@@ -871,16 +826,10 @@ def _validate_action_cel(
return
if isinstance(action, FlowCrewActionDefinition):
if action.with_ is not None:
Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.with.inputs",
)
if action.inputs is not None:
Expression(cast(ExpressionData, action.inputs)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.inputs",
)
Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.with.inputs",
)
return
if isinstance(action, FlowAgentActionDefinition):
@@ -921,6 +870,14 @@ def _validate_action_cel(
def log_flow_definition_issues(definition: FlowDefinition) -> None:
for method_name, method in definition.methods.items():
path = f"methods.{method_name}"
if method.router and not method.is_start and method.listen is None:
_log_flow_definition_issue(
definition.name,
code="router_without_trigger",
severity="error",
path=path,
message="router: true requires either start or listen",
)
if method.emit and not method.router:
_log_flow_definition_issue(
definition.name,

View File

@@ -2455,6 +2455,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
object.__setattr__(
self, "_deferred_flow_started_event_id", started_event.event_id
)
if not self.suppress_flow_events:
self._log_flow_event(
f"Flow started with ID: {self.flow_id}", color="bold magenta"
)
# After FlowStarted: env events must not pre-empt trace batch init
# with implicit "crew" execution_type.
get_env_context()
@@ -3002,7 +3007,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
"""
# First, handle routers repeatedly until no router triggers anymore
router_results = []
router_result_payloads: dict[str, Any] = {}
router_result_to_feedback: dict[
str, Any
] = {} # Map outcome -> HumanFeedbackResult
@@ -3040,11 +3044,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
router_result_str = str(router_result)
router_result_event = FlowMethodName(router_result_str)
router_results.append(router_result_event)
router_result_payloads[router_result_str] = (
self.last_human_feedback
if self.last_human_feedback is not None
else router_result
)
if self.last_human_feedback is not None:
router_result_to_feedback[router_result_str] = (
@@ -3065,7 +3064,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
current_trigger, router_only=False
)
if listeners_triggered:
listener_result = router_result_payloads.get(
listener_result = router_result_to_feedback.get(
str(current_trigger), result
)
racing_group = self._get_racing_group_for_listeners(

View File

@@ -8,7 +8,6 @@ from collections.abc import Awaitable, Callable
import contextvars
import inspect
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, cast
from crewai.flow.expressions import Expression, ExpressionData
@@ -129,34 +128,16 @@ class CrewAction:
self.definition = definition
async def run(self, *_args: Any, **kwargs: Any) -> Any:
from crewai.project.crew_loader import load_crew, load_crew_from_definition
from crewai.project.crew_loader import load_crew_from_definition
local_context = _pop_local_context(kwargs)
if self.definition.from_declaration is not None:
crew, default_inputs = load_crew(
_resolve_crew_declaration(
self.definition.from_declaration,
base_dir=self.flow._definition.source_dir,
)
)
input_template = {**default_inputs, **(self.definition.inputs or {})}
else:
crew_definition = self.definition.with_
if crew_definition is None:
raise ValueError(
"crew action requires exactly one of from_declaration or with"
)
input_template = {
**crew_definition.inputs,
**(self.definition.inputs or {}),
}
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
crew_definition = self.definition.with_
inputs = Expression.from_flow(
cast(ExpressionData, input_template),
cast(ExpressionData, crew_definition.inputs),
self.flow,
local_context=local_context,
).render_template()
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
return await crew.kickoff_async(inputs=inputs)
@@ -378,29 +359,3 @@ def _pop_local_context(kwargs: dict[str, Any]) -> LocalContext | None:
if not isinstance(local_context, dict):
raise TypeError("flow definition local context must be a mapping")
return cast(LocalContext, local_context)
def _resolve_crew_declaration(
from_declaration: str, *, base_dir: Path | None = None
) -> Path:
path = Path(from_declaration).expanduser()
if base_dir is not None:
resolved_base_dir = base_dir.expanduser().resolve()
if not path.is_absolute():
path = resolved_base_dir / path
resolved_path = path.resolve()
if not resolved_path.is_relative_to(resolved_base_dir):
raise ValueError(
"crew declaration path must be within the flow definition directory"
)
path = resolved_path
if not path.is_dir():
return path
for name in ("crew.jsonc", "crew.json"):
candidate = path / name
if candidate.is_file():
return candidate
return path / "crew.jsonc"

View File

@@ -11,10 +11,11 @@ import pytest
from crewai_cli.cli import run
from crewai_cli.run_crew import (
_execute_uv_script,
CrewType,
_load_json_crew_for_tui,
_missing_input_names,
_prompt_for_missing_inputs,
execute_command,
)
@@ -29,8 +30,6 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu
run_crew_mock.assert_called_once_with(
trained_agents_file="my_custom_trained.pkl",
definition=None,
inputs=None,
)
assert result.exit_code == 0
@@ -39,11 +38,7 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu
def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliRunner) -> None:
result = runner.invoke(run)
run_crew_mock.assert_called_once_with(
trained_agents_file=None,
definition=None,
inputs=None,
)
run_crew_mock.assert_called_once_with(trained_agents_file=None)
assert result.exit_code == 0
@@ -55,11 +50,7 @@ def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliR
def test_execute_command_sets_env_var_when_filename_provided(
_build_env: mock.Mock, subprocess_run: mock.Mock
) -> None:
_execute_uv_script(
"run_crew",
entity_type="crew",
trained_agents_file="my_custom_trained.pkl",
)
execute_command(CrewType.STANDARD, trained_agents_file="my_custom_trained.pkl")
_, kwargs = subprocess_run.call_args
assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom_trained.pkl"
@@ -74,7 +65,7 @@ def test_execute_command_sets_env_var_when_filename_provided(
def test_execute_command_omits_env_var_when_filename_absent(
_build_env: mock.Mock, subprocess_run: mock.Mock
) -> None:
_execute_uv_script("run_crew", entity_type="crew")
execute_command(CrewType.STANDARD)
_, kwargs = subprocess_run.call_args
assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"]

View File

@@ -386,54 +386,6 @@ def test_router_runtime_uses_flow_definition_without_legacy_router_metadata():
assert execution_order == ["begin", "decide", "handle_left"]
def test_start_router_runtime_routes_public_dsl_return_value():
execution_order = []
class StartRouterFlow(Flow):
@start()
@router(emit=["continue"])
def decide(self):
execution_order.append("decide")
return "continue"
@listen("continue")
def handle_continue(self, result):
execution_order.append(f"handle_continue:{result}")
return "done"
assert StartRouterFlow().kickoff() == "done"
assert execution_order == ["decide", "handle_continue:continue"]
def test_start_router_runtime_chains_to_stacked_listener_router():
execution_order = []
class ChainedStartRouterFlow(Flow):
@start()
@router(emit=["approved", "not_approved"])
def first_router(self):
execution_order.append("first_router")
return "approved"
@listen("approved")
@router(emit=["second_approval", "not_approved"])
def second_router(self):
execution_order.append("second_router")
return "second_approval"
@listen("second_approval")
def handle_second_approval(self, result):
execution_order.append(f"handle_second_approval:{result}")
return "done"
assert ChainedStartRouterFlow().kickoff() == "done"
assert execution_order == [
"first_router",
"second_router",
"handle_second_approval:second_approval",
]
def test_router_falsy_result_emits_runtime_event():
execution_order = []

View File

@@ -565,54 +565,6 @@ def test_flow_definition_classifies_start_router_from_human_feedback_emit():
assert entry_point.emit is None
def test_flow_definition_classifies_public_dsl_start_router():
class StartRouterFlow(Flow):
@start()
@router(emit=["continue", "stop"])
def entry_point(self):
return "continue"
@router(emit=["resume"])
@start()
def alternate_entry_point(self):
return "resume"
entry_point = StartRouterFlow.flow_definition().methods["entry_point"]
alternate_entry_point = StartRouterFlow.flow_definition().methods[
"alternate_entry_point"
]
assert entry_point.is_start is True
assert entry_point.router is True
assert entry_point.listen is None
assert entry_point.emit == ["continue", "stop"]
assert alternate_entry_point.is_start is True
assert alternate_entry_point.router is True
assert alternate_entry_point.listen is None
assert alternate_entry_point.emit == ["resume"]
def test_flow_definition_merges_stacked_listen_router():
class ChainedRouterFlow(Flow):
@start()
@router(emit=["approved", "not_approved"])
def first_router(self):
return "approved"
@listen("approved")
@router(emit=["second_approval", "not_approved"])
def second_router(self):
return "second_approval"
methods = ChainedRouterFlow.flow_definition().methods
assert methods["first_router"].is_start is True
assert methods["first_router"].listen is None
assert methods["second_router"].router is True
assert methods["second_router"].listen == "approved"
assert methods["second_router"].emit == ["second_approval", "not_approved"]
def test_flow_definition_round_trips_json_and_yaml():
class RoundTripFlow(Flow):
@start()
@@ -931,7 +883,7 @@ def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
assert "diagnostics" not in definition.to_dict()
def test_router_start_false_without_listen_is_allowed(caplog):
def test_router_start_false_without_listen_logs_missing_trigger(caplog):
caplog.set_level(logging.ERROR, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
@@ -949,7 +901,12 @@ def test_router_start_false_without_listen_is_allowed(caplog):
}
)
assert not caplog.records
assert any(
record.levelno == logging.ERROR
and "router_without_trigger" in record.message
and "methods.decision" in record.message
for record in caplog.records
)
def test_router_human_feedback_preserves_existing_router_metadata():
@@ -1091,7 +1048,7 @@ def test_flow_definition_cache_is_not_reused_by_subclasses():
assert set(child_definition.methods) == {"child_step"}
def test_flow_definition_allows_router_without_trigger(caplog):
def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
@@ -1108,11 +1065,9 @@ def test_flow_definition_allows_router_without_trigger(caplog):
}
)
class StandaloneRouterFlow(Flow):
@router(emit=["continue"])
def decision(self):
return "continue"
StandaloneRouterFlow.flow_definition()
assert not caplog.records
assert any(
record.levelno == logging.ERROR
and "LoadedFlow" in record.message
and "router_without_trigger" in record.message
for record in caplog.records
)

View File

@@ -1005,8 +1005,8 @@ methods:
description: Research {topic}
expected_output: Findings about {topic}
agent: researcher
inputs:
topic: "${state.topic}"
inputs:
topic: "${state.topic}"
start: true
"""
@@ -1020,183 +1020,6 @@ methods:
}
def test_crew_action_runs_crew_from_declaration(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
from crewai import Crew
project_root = tmp_path / "project"
crew_root = project_root / "crews" / "research_crew"
agents_root = crew_root / "agents"
agents_root.mkdir(parents=True)
(agents_root / "researcher.jsonc").write_text(
"""
{
"role": "Researcher",
"goal": "Research {topic}",
"backstory": "Knows things."
}
""",
encoding="utf-8",
)
(crew_root / "crew.jsonc").write_text(
"""
{
"name": "referenced_research",
"agents": ["researcher"],
"tasks": [
{
"name": "research_task",
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher"
}
],
"inputs": {
"topic": "Default topic",
"audience": "developers"
}
}
""",
encoding="utf-8",
)
async def fake_kickoff_async(
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
) -> dict[str, Any]:
return {
"crew": self.name,
"tasks": [task.description for task in self.tasks],
"inputs": inputs,
}
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
monkeypatch.chdir(project_root)
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
from_declaration: crews/research_crew
inputs:
topic: "${state.topic}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "referenced_research",
"tasks": ["Research {topic}"],
"inputs": {"topic": "AI", "audience": "developers"},
}
def test_crew_action_from_declaration_resolves_relative_to_flow_file(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
from crewai import Crew
project_root = tmp_path / "project"
crew_root = project_root / "crews" / "research_crew"
agents_root = crew_root / "agents"
agents_root.mkdir(parents=True)
(agents_root / "researcher.jsonc").write_text(
"""
{
"role": "Researcher",
"goal": "Research {topic}",
"backstory": "Knows things."
}
""",
encoding="utf-8",
)
(crew_root / "crew.jsonc").write_text(
"""
{
"name": "relative_research",
"agents": ["researcher"],
"tasks": [
{
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher"
}
],
"inputs": {
"topic": "Default topic"
}
}
""",
encoding="utf-8",
)
async def fake_kickoff_async(
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
) -> dict[str, Any]:
return {"crew": self.name, "inputs": inputs}
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
flow_path = project_root / "flow.yaml"
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
from_declaration: crews/research_crew
inputs:
topic: "${state.topic}"
start: true
"""
flow_path.write_text(yaml_str, encoding="utf-8")
other_cwd = tmp_path / "other"
other_cwd.mkdir()
monkeypatch.chdir(other_cwd)
flow = Flow.from_definition(
FlowDefinition.from_yaml(yaml_str, source_path=flow_path)
)
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "relative_research",
"inputs": {"topic": "AI"},
}
def test_crew_action_from_declaration_rejects_paths_outside_flow_file(
tmp_path: Path,
):
flow_path = tmp_path / "project" / "flow.yaml"
flow_path.parent.mkdir()
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
from_declaration: ../outside/crew.jsonc
start: true
"""
flow = Flow.from_definition(
FlowDefinition.from_yaml(yaml_str, source_path=flow_path)
)
with pytest.raises(
ValueError,
match="crew declaration path must be within the flow definition directory",
):
flow.kickoff()
def test_crew_action_round_trips_with_inline_definition():
definition = FlowDefinition.from_dict(
{
@@ -1224,8 +1047,8 @@ def test_crew_action_round_trips_with_inline_definition():
"agent": "researcher",
}
],
"inputs": {"topic": "${state.topic}"},
},
"inputs": {"topic": "${state.topic}"},
},
}
},
@@ -1239,9 +1062,6 @@ def test_crew_action_round_trips_with_inline_definition():
]["role"]
== "Researcher"
)
assert definition.to_dict()["methods"]["research"]["do"]["inputs"] == {
"topic": "${state.topic}"
}
def test_crew_action_normalizes_named_agent_list_definition():
@@ -1342,7 +1162,7 @@ def test_crew_action_rejects_incomplete_inline_agent_definition():
)
def test_crew_action_rejects_python_ref_field():
def test_crew_action_rejects_ref():
with pytest.raises(ValidationError, match="ref"):
FlowDefinition.from_dict(
{
@@ -1354,6 +1174,7 @@ def test_crew_action_rejects_python_ref_field():
"do": {
"call": "crew",
"ref": "project.crew:build_crew",
"with": {"inputs": {"topic": "AI"}},
},
}
},

View File

@@ -46,16 +46,6 @@ class TestConsoleFormatterPauseResume:
formatter.resume_live_updates()
def test_flow_method_status_ignores_formatter_verbose(self):
formatter = ConsoleFormatter(verbose=False)
with patch.object(formatter, "print_panel") as mock_print_panel:
formatter.handle_method_status("categorize_tickets")
mock_print_panel.assert_called_once()
_, kwargs = mock_print_panel.call_args
assert kwargs["is_flow"] is True
def test_streaming_after_pause_resume_creates_new_session(self):
"""Test that streaming after pause/resume creates new Live session."""
formatter = ConsoleFormatter()

View File

@@ -15,7 +15,7 @@ dev = [
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"pytest-subprocess==1.5.3",
"vcrpy==8.2.1", # pinned, lower versions break pytest-recording
"vcrpy==7.0.0", # pinned, less versions break pytest-recording
"pytest-recording==0.13.4",
"pytest-randomly==4.0.1",
"pytest-timeout==2.4.0",
@@ -171,8 +171,8 @@ info = "Commits must follow Conventional Commits 1.0.0."
[tool.uv]
exclude-newer = "3 days"
# These security fixes are newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z", msgpack = "2026-06-20T00:00:00Z", pydantic-settings = "2026-06-20T00:00:00Z", langsmith = "2026-06-20T00:00:00Z" }
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
# composio-core pins rich<14 but textual requires rich>=14.
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
@@ -188,8 +188,7 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z", msgpack = "2026-06-20T
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
# urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+.
# langsmith <0.8.18 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure)
# and GHSA-f4xh-w4cj-qxq8; force 0.8.18+.
# langsmith <0.8.0 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure); force 0.8.0+.
# authlib <1.6.12 has GHSA-jj8c-mmj3-mmgv (CSRF bypass in cache-based state storage) and PYSEC-2026-188.
# pip 26.1.1 has PYSEC-2026-196; force 26.1.2+.
# aiohttp <=3.13.x has GHSA-jg22-mg44-37j8, GHSA-hg6j-4rv6-33pg; fixed in 3.14.0; force 3.14.0+.
@@ -197,8 +196,6 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z", msgpack = "2026-06-20T
# pip <26.1.1 has GHSA-58qw-9mgm-455v (archive handling); OSV considers 26.1.1 unaffected.
# paramiko <5.0.0 has GHSA-r374-rxx8-8654 (SHA-1 in rsakey.py); OSV considers 5.0.0 unaffected. Transitive via composio-core.
# starlette <1.3.1 has PYSEC-2026-161, GHSA-jp82-jpqv-5vv3, and GHSA-82w8-qh3p-5jfq. Transitive via fastapi.
# msgpack <1.2.1 has GHSA-6v7p-g79w-8964; transitive via pip-audit[filecache].
# pydantic-settings <2.14.2 has GHSA-4xgf-cpjx-pc3j.
# Keep OpenAI on the SDK range required by CrewAI when transitive dependencies
# loosen or pin their own lower versions.
override-dependencies = [
@@ -215,17 +212,16 @@ override-dependencies = [
"uv>=0.11.15,<1",
"python-multipart>=0.0.27,<1",
"gitpython>=3.1.50,<4",
"langsmith>=0.8.18,<1",
"langsmith>=0.8.0,<1",
"authlib>=1.6.12",
"pip>=26.1.2",
"aiohttp>=3.14.0",
# [chunking] carried here because override-dependencies replace the whole
# requirement; without it the docling extra's chunking deps get stripped.
"docling-core[chunking]>=2.74.1",
"pydantic-settings>=2.14.0",
"paramiko>=5.0.0",
"starlette>=1.3.1",
"msgpack>=1.2.1",
"pydantic-settings>=2.14.2",
]
[tool.uv.workspace]

112
uv.lock generated
View File

@@ -17,10 +17,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for
exclude-newer-span = "P3D"
[options.exclude-newer-package]
msgpack = "2026-06-20T00:00:00Z"
langsmith = "2026-06-20T00:00:00Z"
pypdf = "2026-06-18T00:00:00Z"
pydantic-settings = "2026-06-20T00:00:00Z"
[manifest]
members = [
@@ -39,14 +36,13 @@ overrides = [
{ name = "gitpython", specifier = ">=3.1.50,<4" },
{ name = "langchain-core", specifier = ">=1.3.3,<2" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2,<2" },
{ name = "langsmith", specifier = ">=0.8.18,<1" },
{ name = "msgpack", specifier = ">=1.2.1" },
{ name = "langsmith", specifier = ">=0.8.0,<1" },
{ name = "onnxruntime", marker = "python_full_version < '3.11'", specifier = "<1.24" },
{ name = "openai", specifier = ">=2.30.0,<3" },
{ name = "paramiko", specifier = ">=5.0.0" },
{ name = "pillow", specifier = ">=12.1.1" },
{ name = "pip", specifier = ">=26.1.2" },
{ name = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "pydantic-settings", specifier = ">=2.14.0" },
{ name = "pypdf", specifier = ">=6.13.3,<7" },
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
{ name = "rich", specifier = ">=13.7.1" },
@@ -81,7 +77,7 @@ dev = [
{ name = "types-redis", specifier = "~=4.6" },
{ name = "types-regex", specifier = "==2026.1.15.*" },
{ name = "types-requests", specifier = "~=2.31.0.6" },
{ name = "vcrpy", specifier = "==8.2.1" },
{ name = "vcrpy", specifier = "==7.0.0" },
]
[[package]]
@@ -3950,7 +3946,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2
[[package]]
name = "langsmith"
version = "0.8.18"
version = "0.8.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -3964,9 +3960,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/082410ece26ff9f3ed4f87b014a8675be47cbd7d65f06b922045dfc21c47/langsmith-0.8.11.tar.gz", hash = "sha256:d9b3496f8f7ca63f4f2d1dfd368afd6c527923fff2ce4026c82ce85f37db3965", size = 4495842, upload-time = "2026-06-08T22:54:44.395Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" },
{ url = "https://files.pythonhosted.org/packages/b4/65/f9c9dc19b21a9076286fafdb0ab732c9019ddf71aa7e7d720a830a98fe2a/langsmith-0.8.11-py3-none-any.whl", hash = "sha256:08aa5e84b00703ecc11dbeafda78d84b92da4e8c6114e0be9b59df9e71afc59b", size = 478985, upload-time = "2026-06-08T22:54:42.349Z" },
]
[[package]]
@@ -4706,53 +4702,45 @@ wheels = [
[[package]]
name = "msgpack"
version = "1.2.1"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" },
{ url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" },
{ url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" },
{ url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" },
{ url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" },
{ url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" },
{ url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" },
{ url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" },
{ url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" },
{ url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" },
{ url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" },
{ url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" },
{ url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" },
{ url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" },
{ url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" },
{ url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
{ url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
{ url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
{ url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
{ url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
{ url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
{ url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
{ url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
{ url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
{ url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
{ url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
{ url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
{ url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
{ url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" },
{ url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" },
{ url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" },
{ url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" },
{ url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" },
{ url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" },
{ url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" },
{ url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" },
{ url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" },
{ url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" },
{ url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" },
{ url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" },
{ url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" },
{ url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
{ url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
{ url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
{ url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
{ url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
{ url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
{ url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
{ url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
{ url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
{ url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
{ url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
]
[[package]]
@@ -7001,16 +6989,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.14.2"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
]
[[package]]
@@ -9710,15 +9698,17 @@ wheels = [
[[package]]
name = "vcrpy"
version = "8.2.1"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
{ name = "urllib3" },
{ name = "wrapt" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/db/08183b845b0040bb877dad2bd7e4e0976fc232bb3476d7ee369c6c4f8b5a/vcrpy-8.2.1.tar.gz", hash = "sha256:d73a6e4eb6dae8148e659764b7a00e68cc51ba29ba9e6a85e1f0790ad96b97df", size = 90511, upload-time = "2026-06-16T13:20:52.906Z" }
sdist = { url = "https://files.pythonhosted.org/packages/25/d3/856e06184d4572aada1dd559ddec3bedc46df1f2edc5ab2c91121a2cccdb/vcrpy-7.0.0.tar.gz", hash = "sha256:176391ad0425edde1680c5b20738ea3dc7fb942520a48d2993448050986b3a50", size = 85502, upload-time = "2024-12-31T00:07:57.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/7c/0e812ab83f5289404c674f3461ba783250b967d34b5ab034d361236ec042/vcrpy-8.2.1-py3-none-any.whl", hash = "sha256:7ce58c9e2792b246f79d6f4b3e9660676cc6f853be17e1547305b4437ab1ff85", size = 44925, upload-time = "2026-06-16T13:20:51.734Z" },
{ url = "https://files.pythonhosted.org/packages/13/5d/1f15b252890c968d42b348d1e9b0aa12d5bf3e776704178ec37cceccdb63/vcrpy-7.0.0-py2.py3-none-any.whl", hash = "sha256:55791e26c18daa363435054d8b35bd41a4ac441b6676167635d1b37a71dbe124", size = 42321, upload-time = "2024-12-31T00:07:55.277Z" },
]
[[package]]