mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-12 09:18:04 +00:00
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
* feat: add project_id to link OSS usage to an enterprise account Adds a stable per-project identifier so a project's OSS traces and runs can be attributed to an account after signup. There was no such identifier before: [tool.crewai] held only `type`, the deploy UUID was printed to the console but never persisted, Settings.org_uuid is global rather than per-project, and trace batches carried only crew_fingerprint/crew_name. The id lives in the project's pyproject.toml, so it is committed with the repository and stays stable across machines, teammates, CI, and containers - unlike a machine- or user-derived identifier, which is unstable in exactly the containerized production environments that matter most. crewai-core: - get_project_id(): read-only lookup of [tool.crewai].project_id. Safe for library code; never creates or modifies anything. - get_or_create_project_id(): mints a uuid4 and persists it, returning (id, created) so callers can tell the user. Best-effort - returns (None, False) for a missing, malformed, or read-only pyproject.toml rather than raising. - Insertion edits the raw TOML text instead of round-tripping through a writer, so comments, key order, and formatting elsewhere survive. The key is placed at the end of the [tool.crewai] table, before the next table header, so it cannot land in a neighbouring section. - LoginPayload and TraceExecutionContext gain optional project_id. Sent on two paths: - Traces: project_id is added to execution_context, which is sent on both the ephemeral and authenticated paths, so a project's traces remain attributable before and after the user creates an account. - Login: `crewai login` already sends the pseudonymous user_identifier on an authenticated request; adding project_id means one request carries account + user + project, which is the link itself. Minting is restricted to CLI commands the user explicitly invoked - `crewai create` for new projects and `crewai run` to backfill existing ones - and is announced when it happens. Library code only ever reads. Silently rewriting a user's pyproject.toml during Crew.kickoff() would be surprising. Privacy: project_id is a random uuid4 in a file the user commits. It is visible in a diff, contains nothing personal, and identifies a project rather than a person - so this needs none of the notice changes that attaching a user identifier to all telemetry would require. Tests: 18 new tests covering minting, stability, table placement, comment and formatting preservation, five pyproject layouts, the neighbouring-table regression, and graceful handling of missing/malformed/read-only files. Verified end-to-end that both create paths mint distinct ids, that the trace payload carries project_id on both the ephemeral and authenticated paths, and that the login payload carries user_identifier and project_id together. Follow-ups, deliberately not included: adding project_id to telemetry spans, and backend persistence of the (account, user_identifier, project_id) triple. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t * refactor: drop the console announcement when minting project_id Minting now happens silently. With no message to print, the (id, created) tuple had no consumer, so simplify the API rather than keep the flag around for a hypothetical caller: - get_or_create_project_id() returns `str | None` instead of `tuple[str | None, bool]`. - Remove crewai_cli.utils.ensure_project_id, which existed only to print the message and discard the flag. The four call sites (crewai create crew, crewai create flow, crewai run, and tool-repository login) now call get_or_create_project_id directly. - Update tests for the simplified signature; still 18 tests covering minting, stability, table placement, formatting preservation, five pyproject layouts, and missing/malformed/read-only handling. Behaviour is otherwise unchanged: minting stays restricted to CLI commands the user invoked, library code still only reads via get_project_id, and a missing or read-only pyproject.toml still returns None rather than raising. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t * fix: harden project_id minting against TOML corruption; address review Several reviewers found ways the raw-text edit could produce invalid TOML. Each is now fixed and covered by a test that fails without the fix. Duplicate project_id key (Cursor bugbot, Copilot x2): - get_project_id() reports a blank or non-string value as "absent", so a file containing `project_id = ""` took the insert path and gained a second project_id line - a duplicate key, and therefore invalid TOML that no tomli-based tool could read afterwards. - _insert_project_id is now _set_project_id: it replaces an existing assignment inside [tool.crewai] instead of appending unconditionally. Table header with a trailing comment (CodeRabbit major, Cursor bugbot): - `[tool.crewai] # config` is valid TOML but failed exact string equality, so the fallback appended a second [tool.crewai] header - a redefined table, also invalid TOML, and silent because get_project_id swallows the resulting decode error. - Added _is_table_header(), which tolerates a trailing comment and does not match similar names such as [tool.crewai-extra]. Writing into malformed TOML (Cursor bugbot, Copilot): - get_or_create_project_id relied on get_project_id, which cannot distinguish "no id" from "unparsable file", so it appended to files it could not parse. - The locked path now parses explicitly and bails on a decode error, and re-parses the updated content before writing, so this feature can never be the reason a project's pyproject.toml stops parsing. Concurrency and atomicity (CodeRabbit major): - Two CLI processes could both see no id, mint different uuids, and clobber each other, leaving a caller holding an id that is not on disk. Minting now takes the existing crewai_core cross-process lock, re-reads under it, and returns the id that persists. - Writes go through a temp file in the same directory plus os.replace, so an interruption cannot truncate pyproject.toml. File mode is copied across, and the temp file is removed on failure. - os.replace only needs a writable directory, which would have let an atomic write silently overwrite a file the user marked read-only; writability is now checked explicitly so that case still returns None. Line endings (CodeRabbit): - Path.read_text/write_text normalized CRLF to LF, so minting would rewrite a CRLF-committed file entirely. Read and write now use newline="" and the inserted line ending is derived from the existing content. Default create path skipped minting (Cursor bugbot): - `crewai create crew` defaults to create_json_crew; only the --classic and flow paths minted, so most new projects had no id until a later command. Wired into create_json_crew as well. Verified all three paths now mint distinct ids. Do not mint during login (CodeRabbit major): - ToolCommand.login ran get_or_create_project_id, which is outside the sanctioned minting commands and is invoked by `crewai tools create` from a freshly scaffolded directory before the project is persisted. It now uses the read-only get_project_id. Verified login leaves pyproject.toml untouched. Not applied: Copilot asked for a console message when an id is written, in create_crew and create_flow. Minting was made deliberately silent in the previous commit, so the (id, created) tuple and the announcement are both gone by design. Tests: 32 in test_project_id.py, up from 18. New cases cover blank and non-string existing ids, three commented-header forms, similar table names, malformed input, CRLF and LF preservation, concurrent minting convergence, file-mode preservation, and temp-file cleanup. Confirmed the header and duplicate-key tests fail when the fixes are reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t * fix: never create [tool.crewai], treat whitespace ids as absent, harden test `crewai run` could rewrite unrelated projects (Cursor bugbot, high): - get_or_create_project_id ran before the cwd was established as a CrewAI project, and _set_project_id appended a [tool.crewai] table when none existed. Any directory with a pyproject.toml could therefore gain one - including on `crewai run --definition`, which may otherwise succeed. - _set_project_id no longer creates the table; it returns None when [tool.crewai] is absent, so a key is only ever added to a table the project already declares. The templates all ship the table, so no create path needs the old fallback. - The minting call in run_crew moved after the --definition early return, so an explicit-flow run does not touch the cwd at all. - Presence is checked, not truthiness: an empty [tool.crewai] is still a CrewAI marker, and get_crewai_project_config returns {} both for that and for an absent table. - Verified an unrelated project's pyproject.toml is byte-identical after a mint attempt. Whitespace-only project_id accepted as valid (CodeRabbit): - `project_id = " "` is truthy, so it was returned as an identity and would have propagated into login payloads and tracing context. It also meant the '" "' parameter of the replacement test asserted nothing. - Added _usable_project_id, which strips before deciding, used by both get_project_id and the locked mint path. Concurrency test could hang CI (CodeRabbit, major): - Neither the barrier nor the joins had timeouts, so a thread dying early or blocking on the lock would hang the job rather than fail it. The result count was also unchecked, so a dead thread still passed. - Added timeouts, an explicit liveness assertion, a result-count assertion, a lock around the shared result list, and corrected the docstring: this covers the read-modify-write race with threads, not the cross-process backend. Tests: 35, up from 32. New coverage for the absent-table refusal and three whitespace forms; the blank-id replacement case now asserts a real uuid replaced the blank value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t * chore(deps): force gitpython 3.1.57+ for GHSA-p538-c434-8v24 and GHSA-3f7w-8rr8-f37f Unrelated to project_id; bundled here only because it blocks this PR's vulnerability scan. Two advisories were published for gitpython 3.1.55 after main last passed the scan: - GHSA-p538-c434-8v24: arbitrary file truncation via `git rev-list --output` argument injection. Fixed in 3.1.56. - GHSA-3f7w-8rr8-f37f: unguarded git option forwarding in IndexFile.checkout() and TagReference. Fixed in 3.1.57. - Bump the override floor to gitpython>=3.1.57 and declare the same floor in crewai-tools, so consumers installing the published package are covered and not only this repo's lock. - 3.1.57 was published 2026-07-26, past gitpython's exclude-newer-package cutoff of 2026-07-24, so that cutoff moves to 2026-07-27. Without it the floor is unresolvable. pip-audit against the updated lock reports no known vulnerabilities. Verified gitpython 3.1.57 resolves and that crewai_tools and crewai_cli.git still import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNumDnNbiyw3pv1WakAe6t --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
|
|
import click
|
|
import tomli
|
|
|
|
from crewai_cli.constants import ENV_VARS, MODELS
|
|
from crewai_cli.git import initialize_if_git_available
|
|
from crewai_cli.provider import (
|
|
get_provider_data,
|
|
select_model,
|
|
select_provider,
|
|
)
|
|
from crewai_cli.utils import (
|
|
copy_template,
|
|
get_or_create_project_id,
|
|
is_dmn_mode_enabled,
|
|
load_env_vars,
|
|
write_env_file,
|
|
)
|
|
|
|
|
|
def get_reserved_script_names() -> set[str]:
|
|
"""Get reserved script names from pyproject.toml template.
|
|
|
|
Returns:
|
|
Set of reserved script names that would conflict with crew folder names.
|
|
"""
|
|
package_dir = Path(__file__).parent
|
|
template_path = package_dir / "templates" / "crew" / "pyproject.toml"
|
|
|
|
with open(template_path, "r") as f:
|
|
template_content = f.read()
|
|
|
|
template_content = template_content.replace("{{folder_name}}", "_placeholder_")
|
|
template_content = template_content.replace("{{name}}", "placeholder")
|
|
template_content = template_content.replace("{{crew_name}}", "Placeholder")
|
|
|
|
template_data = tomli.loads(template_content)
|
|
script_names = set(template_data.get("project", {}).get("scripts", {}).keys())
|
|
script_names.discard("_placeholder_")
|
|
return script_names
|
|
|
|
|
|
def create_folder_structure(
|
|
name: str, parent_folder: str | None = None
|
|
) -> tuple[Path, str, str]:
|
|
import keyword
|
|
import re
|
|
|
|
name = name.rstrip("/")
|
|
|
|
if not name.strip():
|
|
raise ValueError("Project name cannot be empty or contain only whitespace")
|
|
|
|
folder_name = name.replace(" ", "_").replace("-", "_").lower()
|
|
folder_name = re.sub(r"[^a-zA-Z0-9_]", "", folder_name)
|
|
|
|
if re.match(r"^[^a-zA-Z0-9_-]+", name):
|
|
raise ValueError(
|
|
f"Project name '{name}' contains no valid characters for a Python module name"
|
|
)
|
|
|
|
if not folder_name:
|
|
raise ValueError(
|
|
f"Project name '{name}' contains no valid characters for a Python module name"
|
|
)
|
|
|
|
if folder_name[0].isdigit():
|
|
raise ValueError(
|
|
f"Project name '{name}' would generate folder name '{folder_name}' which cannot start with a digit (invalid Python module name)"
|
|
)
|
|
|
|
if keyword.iskeyword(folder_name):
|
|
raise ValueError(
|
|
f"Project name '{name}' would generate folder name '{folder_name}' which is a reserved Python keyword"
|
|
)
|
|
|
|
if not folder_name.isidentifier():
|
|
raise ValueError(
|
|
f"Project name '{name}' would generate invalid Python module name '{folder_name}'"
|
|
)
|
|
|
|
reserved_names = get_reserved_script_names()
|
|
if folder_name in reserved_names:
|
|
raise ValueError(
|
|
f"Project name '{name}' would generate folder name '{folder_name}' which is reserved. "
|
|
f"Reserved names are: {', '.join(sorted(reserved_names))}. "
|
|
"Please choose a different name."
|
|
)
|
|
|
|
class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "")
|
|
|
|
class_name = re.sub(r"[^a-zA-Z0-9_]", "", class_name)
|
|
|
|
if not class_name:
|
|
raise ValueError(
|
|
f"Project name '{name}' contains no valid characters for a Python class name"
|
|
)
|
|
|
|
if class_name[0].isdigit():
|
|
raise ValueError(
|
|
f"Project name '{name}' would generate class name '{class_name}' which cannot start with a digit"
|
|
)
|
|
|
|
original_name_clean = re.sub(
|
|
r"[^a-zA-Z0-9_]", "", name.replace("_", "").replace("-", "").lower()
|
|
)
|
|
if (
|
|
keyword.iskeyword(original_name_clean)
|
|
or keyword.iskeyword(class_name)
|
|
or class_name in ("True", "False", "None")
|
|
):
|
|
raise ValueError(
|
|
f"Project name '{name}' would generate class name '{class_name}' which is a reserved Python keyword"
|
|
)
|
|
|
|
if not class_name.isidentifier():
|
|
raise ValueError(
|
|
f"Project name '{name}' would generate invalid Python class name '{class_name}'"
|
|
)
|
|
|
|
if parent_folder:
|
|
folder_path = Path(parent_folder) / folder_name
|
|
else:
|
|
folder_path = Path(folder_name)
|
|
|
|
if folder_path.exists():
|
|
if is_dmn_mode_enabled():
|
|
raise click.ClickException(f"Folder {folder_name} already exists.")
|
|
if not click.confirm(
|
|
f"Folder {folder_name} already exists. Do you want to override it?"
|
|
):
|
|
click.secho("Operation cancelled.", fg="yellow")
|
|
sys.exit(0)
|
|
click.secho(f"Overriding folder {folder_name}...", fg="green", bold=True)
|
|
shutil.rmtree(folder_path)
|
|
|
|
click.secho(
|
|
f"Creating {'crew' if parent_folder else 'folder'} {folder_name}...",
|
|
fg="green",
|
|
bold=True,
|
|
)
|
|
|
|
folder_path.mkdir(parents=True)
|
|
(folder_path / "tests").mkdir(exist_ok=True)
|
|
(folder_path / "knowledge").mkdir(exist_ok=True)
|
|
if not parent_folder:
|
|
(folder_path / "src" / folder_name).mkdir(parents=True)
|
|
(folder_path / "src" / folder_name / "tools").mkdir(parents=True)
|
|
(folder_path / "src" / folder_name / "config").mkdir(parents=True)
|
|
|
|
package_dir = Path(__file__).parent
|
|
agents_md_src = package_dir / "templates" / "AGENTS.md"
|
|
if agents_md_src.exists():
|
|
shutil.copy2(agents_md_src, folder_path / "AGENTS.md")
|
|
|
|
return folder_path, folder_name, class_name
|
|
|
|
|
|
def copy_template_files(
|
|
folder_path: Path, name: str, class_name: str, parent_folder: str | None
|
|
) -> None:
|
|
package_dir = Path(__file__).parent
|
|
templates_dir = package_dir / "templates" / "crew"
|
|
|
|
root_template_files = (
|
|
[
|
|
".gitignore",
|
|
"pyproject.toml",
|
|
"README.md",
|
|
"knowledge/user_preference.txt",
|
|
]
|
|
if not parent_folder
|
|
else []
|
|
)
|
|
tools_template_files = ["tools/custom_tool.py", "tools/__init__.py"]
|
|
config_template_files = ["config/agents.yaml", "config/tasks.yaml"]
|
|
src_template_files = (
|
|
["__init__.py", "main.py", "crew.py"] if not parent_folder else ["crew.py"]
|
|
)
|
|
|
|
for file_name in root_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = folder_path / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_path.name)
|
|
|
|
src_folder = (
|
|
folder_path / "src" / folder_path.name if not parent_folder else folder_path
|
|
)
|
|
|
|
for file_name in src_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = src_folder / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_path.name)
|
|
|
|
if not parent_folder:
|
|
for file_name in tools_template_files + config_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = src_folder / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_path.name)
|
|
|
|
|
|
def create_crew(
|
|
name: str,
|
|
provider: str | None = None,
|
|
skip_provider: bool = False,
|
|
parent_folder: str | None = None,
|
|
) -> None:
|
|
folder_path, folder_name, class_name = create_folder_structure(name, parent_folder)
|
|
env_vars = load_env_vars(folder_path)
|
|
if is_dmn_mode_enabled():
|
|
skip_provider = True
|
|
if not skip_provider:
|
|
if not provider:
|
|
provider_models = get_provider_data()
|
|
if not provider_models:
|
|
return
|
|
|
|
existing_provider = None
|
|
for provider, env_keys in ENV_VARS.items():
|
|
if any(
|
|
"key_name" in details and details["key_name"] in env_vars
|
|
for details in env_keys
|
|
):
|
|
existing_provider = provider
|
|
break
|
|
|
|
if existing_provider:
|
|
if not click.confirm(
|
|
f"Found existing environment variable configuration for {existing_provider.capitalize()}. Do you want to override it?"
|
|
):
|
|
click.secho("Keeping existing provider configuration.", fg="yellow")
|
|
return
|
|
|
|
provider_models = get_provider_data()
|
|
if not provider_models:
|
|
return
|
|
|
|
while True:
|
|
selected_provider = select_provider(provider_models)
|
|
if selected_provider is None:
|
|
click.secho("Exiting...", fg="yellow")
|
|
sys.exit(0)
|
|
if selected_provider and isinstance(selected_provider, str):
|
|
break
|
|
click.secho(
|
|
"No provider selected. Please try again or press 'q' to exit.", fg="red"
|
|
)
|
|
|
|
if MODELS.get(selected_provider):
|
|
while True:
|
|
selected_model = select_model(selected_provider, provider_models)
|
|
if selected_model is None:
|
|
click.secho("Exiting...", fg="yellow")
|
|
sys.exit(0)
|
|
if selected_model:
|
|
break
|
|
click.secho(
|
|
"No model selected. Please try again or press 'q' to exit.",
|
|
fg="red",
|
|
)
|
|
env_vars["MODEL"] = selected_model
|
|
|
|
if selected_provider in ENV_VARS:
|
|
provider_env_vars = ENV_VARS[selected_provider]
|
|
for details in provider_env_vars:
|
|
if details.get("default", False):
|
|
for key, value in details.items():
|
|
if key not in ["prompt", "key_name", "default"]:
|
|
env_vars[key] = value
|
|
elif "key_name" in details:
|
|
prompt = details["prompt"]
|
|
key_name = details["key_name"]
|
|
api_key_value = click.prompt(prompt, default="", show_default=False)
|
|
|
|
if api_key_value.strip():
|
|
env_vars[key_name] = api_key_value
|
|
|
|
if env_vars:
|
|
write_env_file(folder_path, env_vars)
|
|
click.secho("API keys and model saved to .env file", fg="green")
|
|
else:
|
|
click.secho(
|
|
"No API keys provided. Skipping .env file creation.", fg="yellow"
|
|
)
|
|
|
|
click.secho(f"Selected model: {env_vars.get('MODEL', 'N/A')}", fg="green")
|
|
|
|
package_dir = Path(__file__).parent
|
|
templates_dir = package_dir / "templates" / "crew"
|
|
|
|
root_template_files = (
|
|
[".gitignore", "pyproject.toml", "README.md", "knowledge/user_preference.txt"]
|
|
if not parent_folder
|
|
else []
|
|
)
|
|
tools_template_files = ["tools/custom_tool.py", "tools/__init__.py"]
|
|
config_template_files = ["config/agents.yaml", "config/tasks.yaml"]
|
|
src_template_files = (
|
|
["__init__.py", "main.py", "crew.py"] if not parent_folder else ["crew.py"]
|
|
)
|
|
|
|
for file_name in root_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = folder_path / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_name)
|
|
|
|
src_folder = folder_path / "src" / folder_name if not parent_folder else folder_path
|
|
|
|
for file_name in src_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = src_folder / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_name)
|
|
|
|
if not parent_folder:
|
|
for file_name in tools_template_files + config_template_files:
|
|
src_file = templates_dir / file_name
|
|
dst_file = src_folder / file_name
|
|
copy_template(src_file, dst_file, name, class_name, folder_name)
|
|
|
|
if not parent_folder:
|
|
# Minted at creation so the project has a stable identity from run one.
|
|
get_or_create_project_id(folder_path / "pyproject.toml")
|
|
initialize_if_git_available(folder_path)
|
|
|
|
click.secho(f"Crew {name} created successfully!", fg="green", bold=True)
|