diff --git a/lib/cli/src/crewai_cli/create_crew.py b/lib/cli/src/crewai_cli/create_crew.py index 549fc023e..334c398f2 100644 --- a/lib/cli/src/crewai_cli/create_crew.py +++ b/lib/cli/src/crewai_cli/create_crew.py @@ -14,6 +14,7 @@ from crewai_cli.provider import ( ) from crewai_cli.utils import ( copy_template, + get_or_create_project_id, is_dmn_mode_enabled, load_env_vars, write_env_file, @@ -320,6 +321,8 @@ def create_crew( 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) diff --git a/lib/cli/src/crewai_cli/create_flow.py b/lib/cli/src/crewai_cli/create_flow.py index 7921c2847..8345c95f4 100644 --- a/lib/cli/src/crewai_cli/create_flow.py +++ b/lib/cli/src/crewai_cli/create_flow.py @@ -5,6 +5,7 @@ import click from crewai_core.telemetry import Telemetry from crewai_cli.git import initialize_if_git_available +from crewai_cli.utils import get_or_create_project_id from crewai_cli.version import get_crewai_tools_dependency @@ -31,6 +32,8 @@ def create_flow(name: str, *, declarative: bool = False) -> None: else: _create_python_flow(name, class_name, folder_name, project_root) + # Minted at creation so the project has a stable identity from run one. + get_or_create_project_id(project_root / "pyproject.toml") initialize_if_git_available(project_root) click.secho(f"Flow {name} created successfully!", fg="green", bold=True) diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index c4116e1b6..55d357dae 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -20,6 +20,7 @@ from crewai_cli.input_prompt import ( ) from crewai_cli.utils import ( build_env_with_all_tool_credentials, + ensure_project_id, is_dmn_mode_enabled, ) from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version @@ -617,6 +618,10 @@ def run_crew( or declarative (JSON) crew. Layered over the definition's own defaults; missing required values are prompted for interactively. """ + # Backfills projects created before project_id existed. Only here, in a + # command the user explicitly invoked - never from the SDK during kickoff. + ensure_project_id() + # --definition is a pure override: run that flow directly. if definition is not None: _run_explicit_declarative_flow( diff --git a/lib/cli/src/crewai_cli/tools/main.py b/lib/cli/src/crewai_cli/tools/main.py index 9917f097a..4d3d54e57 100644 --- a/lib/cli/src/crewai_cli/tools/main.py +++ b/lib/cli/src/crewai_cli/tools/main.py @@ -16,6 +16,7 @@ from crewai_cli.config import Settings from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL from crewai_cli.utils import ( build_env_with_tool_repository_credentials, + ensure_project_id, get_project_description, get_project_name, get_project_version, @@ -229,7 +230,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin): def login(self) -> None: get_user_id = _require_get_user_id() login_response = self.plus_api_client.login_to_tool_repository( - user_identifier=get_user_id() + user_identifier=get_user_id(), + project_id=ensure_project_id(), ) if login_response.status_code != 200: diff --git a/lib/cli/src/crewai_cli/utils.py b/lib/cli/src/crewai_cli/utils.py index e20bcfea1..7fb26f80a 100644 --- a/lib/cli/src/crewai_cli/utils.py +++ b/lib/cli/src/crewai_cli/utils.py @@ -9,7 +9,9 @@ from typing import Any import click from crewai_core.project import ( + get_or_create_project_id as get_or_create_project_id, get_project_description as get_project_description, + get_project_id as get_project_id, get_project_name as get_project_name, get_project_version as get_project_version, parse_toml as parse_toml, @@ -29,8 +31,11 @@ __all__ = [ "build_env_with_tool_repository_credentials", "copy_template", "enable_prompt_line_editing", + "ensure_project_id", "fetch_and_json_env_file", + "get_or_create_project_id", "get_project_description", + "get_project_id", "get_project_name", "get_project_version", "is_dmn_mode_enabled", @@ -48,6 +53,32 @@ console = Console() _TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}") +def ensure_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None: + """Return the project's id, minting one and telling the user if it was added. + + Safe to call from any CLI command: returns None rather than raising when + there is no project, or when ``pyproject.toml`` is not writable. + + Args: + pyproject_path: Path to the project's ``pyproject.toml``. + + Returns: + The project id, or None if one could not be read or created. + """ + project_id, created = get_or_create_project_id(pyproject_path) + + if created: + console.print( + f"Added [bold]project_id[/bold] to {pyproject_path} under " + # Escaped: Rich would otherwise parse [tool.crewai] as a style tag. + r"[bold]\[tool.crewai][/bold] so this project's runs and traces stay " + "linked. Commit it to share that link with your team.", + style="dim", + ) + + return project_id + + def is_dmn_mode_enabled() -> bool: """Return True when the enterprise non-interactive mode is enabled.""" value = os.environ.get("CREWAI_DMN") diff --git a/lib/crewai-core/src/crewai_core/plus_api.py b/lib/crewai-core/src/crewai_core/plus_api.py index 4ed5e640d..c1acbad4e 100644 --- a/lib/crewai-core/src/crewai_core/plus_api.py +++ b/lib/crewai-core/src/crewai_core/plus_api.py @@ -69,7 +69,7 @@ class _WithUserIdentifier(TypedDict): class LoginPayload(_WithUserIdentifier): - pass + project_id: NotRequired[str] class TraceExecutionContext(TypedDict): @@ -78,6 +78,7 @@ class TraceExecutionContext(TypedDict): flow_name: str | None crewai_version: str privacy_level: str + project_id: NotRequired[str | None] class TraceExecutionMetadata(TypedDict): @@ -229,11 +230,24 @@ class PlusAPI: return client.request(method, url, files=files, **request_kwargs) def login_to_tool_repository( - self, user_identifier: str | None = None + self, user_identifier: str | None = None, project_id: str | None = None ) -> httpx.Response: + """Log in to the tool repository. + + This request is authenticated, so sending user_identifier and project_id + alongside it links the account to the local pseudonymous user id and to + the project the command was run from - letting prior anonymous usage of + that project be attributed after signup. + + Args: + user_identifier: Local pseudonymous user id. + project_id: ``[tool.crewai].project_id`` of the current project. + """ payload: LoginPayload = {} if user_identifier: payload["user_identifier"] = user_identifier + if project_id: + payload["project_id"] = project_id return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload) def get_tool(self, handle: str) -> httpx.Response: diff --git a/lib/crewai-core/src/crewai_core/project.py b/lib/crewai-core/src/crewai_core/project.py index 9c7e6a33e..2ce1406bf 100644 --- a/lib/crewai-core/src/crewai_core/project.py +++ b/lib/crewai-core/src/crewai_core/project.py @@ -6,6 +6,7 @@ from functools import reduce from pathlib import Path, PureWindowsPath import sys from typing import Any +import uuid from rich.console import Console import tomli @@ -221,3 +222,121 @@ def get_project_description( return _get_project_attribute( pyproject_path, ["project", "description"], require=require ) + + +_PROJECT_ID_KEY = "project_id" + + +def get_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None: + """Return ``[tool.crewai].project_id`` if the project has one. + + Read-only and safe to call from library code: it never creates or modifies + anything. Use this everywhere except the CLI commands that are allowed to + mint an id (see :func:`get_or_create_project_id`). + + Args: + pyproject_path: Path to the project's ``pyproject.toml``. + + Returns: + The project id, or None when the file is missing, unreadable, or has + no id configured. + """ + try: + pyproject_data = read_toml(pyproject_path) + except (OSError, tomli.TOMLDecodeError): + return None + + project_id = get_crewai_project_config(pyproject_data).get(_PROJECT_ID_KEY) + return project_id if isinstance(project_id, str) and project_id else None + + +def get_or_create_project_id( + pyproject_path: str | Path = "pyproject.toml", +) -> tuple[str | None, bool]: + """Return the project's id, minting and persisting one if absent. + + Writes ``project_id`` into the ``[tool.crewai]`` table so it is committed + with the repository. That makes it stable across machines, teammates, CI, + and containers - unlike a machine- or user-derived identifier. + + Only CLI commands the user explicitly invoked should call this. Library + code must use :func:`get_project_id` instead; silently rewriting a user's + ``pyproject.toml`` during ``Crew.kickoff()`` would be surprising. + + Args: + pyproject_path: Path to the project's ``pyproject.toml``. + + Returns: + A ``(project_id, created)`` tuple. ``created`` is True only when an id + was minted and written on this call, so callers can tell the user. Both + values are ``(None, False)`` when the file is missing or not writable - + this is best-effort and never raises. + """ + existing = get_project_id(pyproject_path) + if existing: + return existing, False + + path = Path(pyproject_path) + if not path.is_file(): + return None, False + + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None, False + + project_id = str(uuid.uuid4()) + updated = _insert_project_id(content, project_id) + if updated is None: + return None, False + + try: + path.write_text(updated, encoding="utf-8") + except OSError: + # Read-only checkout, permissions, container FS - not worth failing over. + return None, False + + return project_id, True + + +def _insert_project_id(content: str, project_id: str) -> str | None: + """Add ``project_id`` to the ``[tool.crewai]`` table in TOML source text. + + Edits the raw text rather than round-tripping through a TOML writer so + formatting, ordering, and comments in the rest of the file are preserved. + + Args: + content: Full contents of a ``pyproject.toml``. + project_id: The id to insert. + + Returns: + Updated file contents, or None if the edit could not be made safely. + """ + lines = content.splitlines(keepends=True) + entry = f'{_PROJECT_ID_KEY} = "{project_id}"\n' + + for index, line in enumerate(lines): + if line.strip() != "[tool.crewai]": + continue + + # Insert at the end of the table, before the next table header, so the + # key cannot land inside a different section. + insert_at = len(lines) + for offset in range(index + 1, len(lines)): + if lines[offset].lstrip().startswith("["): + insert_at = offset + break + + # Step back over trailing blank lines so the key stays in the table. + while insert_at > index + 1 and not lines[insert_at - 1].strip(): + insert_at -= 1 + + if insert_at > 0 and not lines[insert_at - 1].endswith("\n"): + lines[insert_at - 1] += "\n" + + lines.insert(insert_at, entry) + return "".join(lines) + + # No [tool.crewai] table: append one rather than guessing where it belongs. + suffix = "" if content.endswith("\n") or not content else "\n" + return f"{content}{suffix}\n[tool.crewai]\n{entry}" diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py index 72bb2452e..6f3abd399 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py @@ -14,6 +14,7 @@ from crewai_core.plus_api import ( TraceExecutionMetadata, TraceFinalizePayload, ) +from crewai_core.project import get_project_id from crewai_core.settings import Settings from rich.console import Console from rich.panel import Panel @@ -145,6 +146,10 @@ class TraceBatchManager: "flow_name": execution_metadata.get("flow_name", None), "crewai_version": self.current_batch.version, "privacy_level": user_context.get("privacy_level", "standard"), + # Read-only: never mints an id. Sent on both the ephemeral and + # authenticated paths, so a project's traces stay attributable + # to it before and after the user creates an account. + "project_id": get_project_id(), } execution_metadata_payload: TraceExecutionMetadata = { "expected_duration_estimate": execution_metadata.get( diff --git a/lib/crewai/tests/telemetry/test_project_id.py b/lib/crewai/tests/telemetry/test_project_id.py new file mode 100644 index 000000000..9b991796d --- /dev/null +++ b/lib/crewai/tests/telemetry/test_project_id.py @@ -0,0 +1,177 @@ +"""Tests for the project_id used to link OSS usage to an enterprise account.""" + +import uuid + +import pytest + +from crewai_core.project import ( + get_or_create_project_id, + get_project_id, + parse_toml, +) + + +CREW_PYPROJECT = """\ +[project] +name = "my_crew" +version = "0.1.0" +dependencies = ["crewai"] + +[tool.crewai] +type = "crew" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +""" + + +@pytest.fixture +def pyproject(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text(CREW_PYPROJECT) + return path + + +def test_returns_none_when_no_id_configured(pyproject): + assert get_project_id(pyproject) is None + + +def test_mints_and_persists_an_id(pyproject): + project_id, created = get_or_create_project_id(pyproject) + + assert created is True + assert uuid.UUID(project_id) + assert get_project_id(pyproject) == project_id + + +def test_id_is_stable_across_calls(pyproject): + first, created_first = get_or_create_project_id(pyproject) + second, created_second = get_or_create_project_id(pyproject) + + assert created_first is True + assert created_second is False, "must not mint a second id" + assert first == second + + +def test_id_lands_in_the_tool_crewai_table(pyproject): + project_id, _ = get_or_create_project_id(pyproject) + + data = parse_toml(pyproject.read_text()) + assert data["tool"]["crewai"]["project_id"] == project_id + assert data["tool"]["crewai"]["type"] == "crew", "existing keys must survive" + + +def test_other_tables_are_preserved(pyproject): + get_or_create_project_id(pyproject) + + data = parse_toml(pyproject.read_text()) + assert data["project"]["name"] == "my_crew" + assert data["project"]["dependencies"] == ["crewai"] + assert data["build-system"]["build-backend"] == "hatchling.build" + + +def test_comments_and_formatting_are_preserved(tmp_path): + """Raw-text editing rather than a TOML round-trip, so comments survive.""" + path = tmp_path / "pyproject.toml" + path.write_text( + '# top comment\n[project]\nname = "x" # inline comment\n\n[tool.crewai]\ntype = "flow"\n' + ) + + get_or_create_project_id(path) + + content = path.read_text() + assert "# top comment" in content + assert "# inline comment" in content + + +@pytest.mark.parametrize( + ("source", "label"), + [ + ('[project]\nname = "x"\n\n[tool.crewai]\ntype = "crew"\n', "table then EOF"), + ('[tool.crewai]\ntype = "crew"', "no trailing newline"), + ('[project]\nname = "x"\n', "no tool.crewai table"), + ('[project]\nname = "x"\n[tool.crewai]\n[other]\na = 1\n', "empty table"), + ( + '[tool.crewai]\ntype = "crew"\n\n\n[build-system]\nrequires = []\n', + "blank lines before next table", + ), + ], +) +def test_produces_valid_toml_for_varied_layouts(tmp_path, source, label): + path = tmp_path / "pyproject.toml" + path.write_text(source) + + project_id, created = get_or_create_project_id(path) + + assert created is True, label + data = parse_toml(path.read_text()) + assert data["tool"]["crewai"]["project_id"] == project_id, label + + +def test_id_does_not_leak_into_a_neighbouring_table(tmp_path): + """The key must never land under [build-system].""" + path = tmp_path / "pyproject.toml" + path.write_text( + '[tool.crewai]\ntype = "crew"\n\n[build-system]\nrequires = ["hatchling"]\n' + ) + + get_or_create_project_id(path) + + data = parse_toml(path.read_text()) + assert "project_id" in data["tool"]["crewai"] + assert "project_id" not in data["build-system"] + + +def test_missing_file_is_not_an_error(tmp_path): + project_id, created = get_or_create_project_id(tmp_path / "nope.toml") + + assert project_id is None + assert created is False + + +def test_malformed_toml_is_not_an_error(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text("this is not [valid toml") + + assert get_project_id(path) is None + + +def test_read_only_file_is_not_an_error(pyproject): + """A read-only checkout must not break the command that called this.""" + pyproject.chmod(0o444) + try: + project_id, created = get_or_create_project_id(pyproject) + finally: + pyproject.chmod(0o644) + + assert project_id is None + assert created is False + + +def test_get_project_id_never_creates_anything(pyproject): + """Library code calls the read-only variant; it must not mutate the file.""" + before = pyproject.read_text() + + assert get_project_id(pyproject) is None + + assert pyproject.read_text() == before + + +def test_blank_id_is_treated_as_absent(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text('[tool.crewai]\ntype = "crew"\nproject_id = ""\n') + + assert get_project_id(path) is None + + +def test_ids_are_unique_across_projects(tmp_path): + ids = set() + for name in ("a", "b", "c"): + path = tmp_path / name / "pyproject.toml" + path.parent.mkdir() + path.write_text(CREW_PYPROJECT) + project_id, _ = get_or_create_project_id(path) + ids.add(project_id) + + assert len(ids) == 3