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
This commit is contained in:
Joao Moura
2026-08-03 10:06:57 -07:00
parent 26518e0dec
commit 7f51bb6137
9 changed files with 362 additions and 3 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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:

View File

@@ -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")