mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-20 18:13:49 +00:00
* feat(telemetry): report project creation with the id minted for it Acquisition was only observable from a project's first run. That misses every project created and never run, and dates the rest to the wrong day. All three scaffolding paths already mint a project_id into the new pyproject.toml. None of them reported it, and two of them - create_crew and create_json_crew, which is the default `crewai create crew` path - emitted no telemetry at all. `Project Created` carries the kind (crew, json_crew, flow) and the id that was just minted, and is emitted after the mint so it can carry it. The attribute is `created_project_id`, not `project_id`, because those are two different things. CommonAttributesSpanProcessor stamps `project_id` on every span from get_project_id(), which reads the current working directory and is cached for the life of the process - during `crewai create` that describes the directory the command was run from, not the project being created. Reusing the name would have given one column two meanings depending on span type. Nothing is emitted for `create_crew(parent_folder=...)`: that adds a crew to a project which already exists, mints no id, and is not an acquisition. The existing `Flow Creation` span is left exactly as it is. Note for whoever reads it: it is emitted from two places with two different meanings - CLI scaffolding (create_flow.py) and runtime flow construction (event_listener.py on FlowCreatedEvent) - so it cannot separate acquisition from usage. Not changed here because it is a live series and renaming it would break continuity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfV2uMqWRcdfufMvtdCVoN * test(telemetry): stop the creation-span tests exporting to the real collector The recorded_spans fixture has to enable telemetry for its assertions to mean anything - a disabled Telemetry never builds self.provider, so every assertion would pass vacuously against a non-recording span. But enabling it is exactly what makes __init__ wire a BatchSpanProcessor around the real SafeOTLPSpanExporter, pointed at the production collector. Measured, with a spy on both SafeOTLPSpanExporter classes reporting at interpreter exit: before this change the file made 3 real export calls, handed 3 synthetic Project Created spans with invented created_project_id values to the production exporter, and completed 3 connects to the collector. After: 0, 0, 0. Sampling at pytest_sessionfinish reports 0 either way and is how this was missed - BatchSpanProcessor flushes on a background timer, and with no provider.shutdown() the flush lands in the atexit handler, which runs after sessionfinish. --block-network does not prevent it: it is function-scoped and only swaps socket.connect, which a background batch thread outlives. Follows telemetry_with_exporter in tests/telemetry/test_tracer_isolation.py: _NullExporter swapped in before construction, _register_shutdown_handlers suppressed so no atexit hook is left behind, and provider.shutdown() in finally. Patch target is crewai_core because that is where this Telemetry comes from. Also disambiguates the docs rows: the minted ID belongs to the new project, not to the directory the command ran in, and the two can differ. Reworded in all four languages rather than renaming the token to created_project_id - this table documents data, never span-attribute keys (kind and crewai_version on the same row are unnamed), and `project_id` is already the page's name for the pyproject.toml key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfV2uMqWRcdfufMvtdCVoN * docs(ar): use tanwin fath on the letter, not on the alif مشروعًا / جديدًا rather than مشروعاً / جديداً, in the row added by this PR. Both forms appear in docs/edge/ar (3 each), so this is not a house convention being broken either way; the corrected form is the more standard one and the text is mine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfV2uMqWRcdfufMvtdCVoN * docs: stop the creation row implying the span's project_id holds the minted value CodeRabbit re-raised this as Major after I rejected the first version, and it was right to. My rejection argued the page never names wire keys, so introducing created_project_id would be its only one. That part still holds -- grep finds no attribute key anywhere in the four files. But it was the wrong conclusion: the row still used the token `project_id` for a value the span does NOT carry under that key, while the same span's real project_id holds the cwd-derived value. The row also already exposes literal wire values (`crew`, `json_crew`, `flow` are the actual kind values), so "this page has no wire detail" was overstated. Dropping the token resolves the ambiguity without adding the page's only key name: the row now says "the project ID minted for that new project", and names `project_id` only to say the minted value is recorded separately from it. All four languages. No docs/v*/ touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfV2uMqWRcdfufMvtdCVoN * docs: use an em dash in the creation row, matching the rest of the page The two ASCII `--` occurrences in these files were both mine, introduced by this PR: the page otherwise uses em dashes throughout (en 4, ar 4, ko 2, pt-BR 4). CodeRabbit flagged pt-BR; the same slip was in en, so both are fixed. Now zero ASCII `--` across all four language files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfV2uMqWRcdfufMvtdCVoN --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
|
|
import click
|
|
from crewai_core.telemetry import Telemetry
|
|
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.
|
|
project_id = get_or_create_project_id(folder_path / "pyproject.toml")
|
|
# Emitted only here, not in the parent_folder branch: that branch adds a crew to
|
|
# an existing project, which is not an acquisition and mints no id.
|
|
Telemetry().project_created_span("crew", project_id)
|
|
initialize_if_git_available(folder_path)
|
|
|
|
click.secho(f"Crew {name} created successfully!", fg="green", bold=True)
|