From 84ffb3619079a3bc9a3018d668bfc037338ed3ea Mon Sep 17 00:00:00 2001 From: Mgeeeek Date: Tue, 12 May 2026 20:36:11 +0530 Subject: [PATCH] feat(cli): Agent Mode bootstrap + claim flow (Python) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New behavior on `mem0 init`: - With no `--email`/`--api-key` AND a positive agent signal (`--agent`, global `--json`/`--agent`, or one of the recognized agent env vars CLAUDECODE / CURSOR_AGENT / CODEX_CLI / CLINE / CONTINUE / AIDER / GOOSE / WINDSURF), bootstrap an unattended Agent Mode account via POST /api/v1/auth/agent_mode/. No email, no OTP, no dashboard. - With `--email ` AND an existing config that has agent_mode=true, run the claim device-flow against the existing key instead of minting a fresh one. The raw API key never leaves the device; backend confirms claim via the existing CLILoginRequest poll path. Config flips agent_mode=false and stamps claimed_at on success. - Bare `mem0 init` with no signal + no TTY still errors out — auto-bootstrap requires a positive agent signal to avoid surprising pipe-using humans. New flags: --agent Force unattended Agent Mode bootstrap. --source Channel attribution string for signup_source PostHog property. Config schema extensions on PlatformConfig: agent_mode, created_via, claimed_at, default_user_id. Telemetry (M1-M6 from the growth doc): - cli.init: mode (agent|email|api_key|existing_key), agent_caller, signup_source, claimed_agent_mode (bool when --email claims an existing agent-mode config). - All cli.* events: agent_mode reflects config.platform.agent_mode (the bootstrap flag), not the output-format flag — per the growth-doc spec. --- cli/python/src/mem0_cli/agent_detect.py | 33 ++++ cli/python/src/mem0_cli/app.py | 21 ++- .../src/mem0_cli/commands/agent_mode_cmd.py | 177 ++++++++++++++++++ cli/python/src/mem0_cli/commands/init_cmd.py | 54 +++++- cli/python/src/mem0_cli/config.py | 13 ++ cli/python/src/mem0_cli/telemetry.py | 6 +- 6 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 cli/python/src/mem0_cli/agent_detect.py create mode 100644 cli/python/src/mem0_cli/commands/agent_mode_cmd.py diff --git a/cli/python/src/mem0_cli/agent_detect.py b/cli/python/src/mem0_cli/agent_detect.py new file mode 100644 index 000000000..8cdbbf137 --- /dev/null +++ b/cli/python/src/mem0_cli/agent_detect.py @@ -0,0 +1,33 @@ +"""Detect which AI agent is invoking the CLI via environment variables. + +Used by `mem0 init` to: + 1. Decide whether to auto-bootstrap an Agent Mode key (positive agent signal). + 2. Tag the `agent_caller` PostHog property on the cli.init event. + +Returns a canonical short name or None when no agent is detected. The list +is curated, not exhaustive — agents we don't recognise fall through to None, +which groups into the "unknown" bucket on dashboards. +""" + +from __future__ import annotations + +import os + +_AGENT_CALLER_ENV: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude-code", ("CLAUDECODE", "CLAUDE_CODE")), + ("cursor", ("CURSOR_AGENT", "CURSOR_SESSION_ID")), + ("codex", ("CODEX_CLI", "OPENAI_CODEX")), + ("cline", ("CLINE_AGENT", "CLINE")), + ("continue", ("CONTINUE_AGENT", "CONTINUE_SESSION")), + ("aider", ("AIDER_SESSION",)), + ("goose", ("GOOSE_AGENT",)), + ("windsurf", ("WINDSURF_AGENT",)), +) + + +def detect_agent_caller() -> str | None: + """Return a canonical agent name if any agent env var is set, else None.""" + for name, env_vars in _AGENT_CALLER_ENV: + if any(os.environ.get(v) for v in env_vars): + return name + return None diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index ef0aed5ab..51e170e5a 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -236,7 +236,8 @@ def main_callback( _fire_telemetry("version") cmd_version() raise typer.Exit() - if ctx.invoked_subcommand: + if ctx.invoked_subcommand and ctx.invoked_subcommand != "init": + # init fires its own telemetry from init_cmd.run_init with full M1-M6 props. _fire_telemetry(ctx.invoked_subcommand) @@ -851,6 +852,12 @@ def init( force: bool = typer.Option( False, "--force", help="Overwrite existing config without confirmation." ), + agent_signal: bool = typer.Option( + False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)." + ), + source: str | None = typer.Option( + None, "--source", help="Channel attribution for signup (e.g. github, hn, ph).", + ), ) -> None: """Interactive setup wizard for mem0 CLI. @@ -859,10 +866,20 @@ def init( mem0 init --api-key m0-xxx --user-id alice mem0 init --email alice@company.com mem0 init --email alice@company.com --code 482901 + mem0 init --agent # Bootstrap an Agent Mode account (unattended) + mem0 init --email alice@company.com # Claims an existing Agent Mode key when one is present """ from mem0_cli.commands.init_cmd import run_init - run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force) + run_init( + api_key=api_key, + user_id=user_id, + email=email, + code=code, + force=force, + source=source, + agent=agent_signal, + ) # (entity_app registered at module level, below sub-group definitions) diff --git a/cli/python/src/mem0_cli/commands/agent_mode_cmd.py b/cli/python/src/mem0_cli/commands/agent_mode_cmd.py new file mode 100644 index 000000000..d19778d91 --- /dev/null +++ b/cli/python/src/mem0_cli/commands/agent_mode_cmd.py @@ -0,0 +1,177 @@ +"""Agent Mode commands — bootstrap (unattended signup) and claim (human upgrade).""" + +from __future__ import annotations + +import secrets +import time +import webbrowser +from datetime import datetime, timezone +from typing import Any + +import httpx +import typer +from rich.console import Console + +from mem0_cli.branding import ( + DIM_COLOR, + print_error, + print_info, + print_success, +) +from mem0_cli.config import Mem0Config, save_config + +console = Console() +err_console = Console(stderr=True) + +# Claim polling: 2-second interval, 10-minute timeout matches the backend's +# CLILoginRequest expires_at (15 minutes — we give up before the token does). +_POLL_INTERVAL_SECS = 2 +_POLL_TIMEOUT_SECS = 600 + +_SOURCE_HEADERS = { + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "python", +} + + +def bootstrap_via_backend( + config: Mem0Config, + *, + source: str | None = None, +) -> None: + """POST /api/v1/auth/agent_mode/ and mutate config in place. + + Returns nothing — the caller saves the config and prints follow-up messages. + Raises typer.Exit(1) on failure. + """ + base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/") + body: dict[str, Any] = {} + if source: + body["source"] = source + + try: + with httpx.Client(timeout=30.0) as client: + resp = client.post( + f"{base_url}/api/v1/auth/agent_mode/", + headers={**_SOURCE_HEADERS, "Content-Type": "application/json"}, + json=body, + ) + except httpx.HTTPError as exc: + print_error(err_console, f"Network error contacting Mem0: {exc}") + raise typer.Exit(1) from exc + + if resp.status_code == 429: + print_error(err_console, "Rate-limited. Try again in a few minutes.") + raise typer.Exit(1) + if resp.status_code == 503: + print_error(err_console, "Agent Mode is temporarily disabled. Try again later.") + raise typer.Exit(1) + if resp.status_code != 200: + try: + detail = resp.json().get("error", resp.text) + except Exception: + detail = resp.text + print_error(err_console, f"Bootstrap failed: {detail}") + raise typer.Exit(1) + + envelope = resp.json() + config.platform.api_key = envelope["api_key"] + config.platform.base_url = base_url + config.platform.agent_mode = True + config.platform.created_via = "agent_mode" + config.platform.claimed_at = "" + config.platform.default_user_id = envelope["default_user_id"] + # Adopt the slug-derived user_id as the default scope for memory ops. + config.defaults.user_id = envelope["default_user_id"] + save_config(config) + + print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}") + console.print(f" [{DIM_COLOR}]To claim this account later: {envelope.get('claim_command', 'mem0 init --email ')}[/]") + + +def claim_via_device_flow(config: Mem0Config, *, email: str) -> None: + """Run the claim flow against an existing agent-mode config. + + Reuses the existing CLI device flow (initiate_cli_login → frontend OTP → + associate_cli_token → get_api_key_from_cli_token poll). The raw API key + never leaves the device — backend confirms claim, CLI updates only + `platform.agent_mode` and `platform.claimed_at`. + """ + base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/") + if not config.platform.api_key or not config.platform.agent_mode: + print_error( + err_console, + "This command requires an active Agent Mode config. Run `mem0 init` first.", + ) + raise typer.Exit(1) + + cli_token = secrets.token_urlsafe(32) + raw_key = config.platform.api_key + + with httpx.Client(timeout=30.0) as client: + try: + init_resp = client.post( + f"{base_url}/api/v1/accounts/cli_login/", + json={"token": cli_token, "claim_for_apikey": raw_key}, + headers=_SOURCE_HEADERS, + ) + except httpx.HTTPError as exc: + print_error(err_console, f"Could not initiate claim: {exc}") + raise typer.Exit(1) from exc + + if init_resp.status_code != 200: + try: + detail = init_resp.json().get("error", init_resp.text) + except Exception: + detail = init_resp.text + print_error(err_console, f"Could not initiate claim: {detail}") + raise typer.Exit(1) + + login_url = init_resp.json().get("login_url", "") + print_info(console, "Open in your browser to claim:") + console.print(f" [{DIM_COLOR}]{login_url}[/]") + try: + webbrowser.open(login_url) + except Exception: + pass # Printing the URL is sufficient + + # Poll for completion + deadline = time.monotonic() + _POLL_TIMEOUT_SECS + while time.monotonic() < deadline: + time.sleep(_POLL_INTERVAL_SECS) + try: + poll = client.post( + f"{base_url}/api/v1/accounts/get_api_key_from_cli_token/", + json={"token": cli_token}, + headers=_SOURCE_HEADERS, + ) + except httpx.HTTPError: + continue # transient — keep polling + + if poll.status_code != 200: + # 400 "Token expired" / "Invalid token" → bail + try: + err = poll.json().get("error", "") + except Exception: + err = poll.text + if "expired" in err.lower(): + print_error(err_console, "Claim link expired. Run `mem0 init --email ` again.") + raise typer.Exit(1) + continue + + body = poll.json() + if body.get("claimed"): + config.platform.agent_mode = False + config.platform.claimed_at = body.get("claimed_at") or _utcnow_iso() + config.platform.user_email = email + config.platform.created_via = "email" + save_config(config) + print_success(console, f"Agent claimed to {email}. Your API key is unchanged.") + return + + print_error(err_console, "Claim timed out. Run `mem0 init --email ` again.") + raise typer.Exit(1) + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/cli/python/src/mem0_cli/commands/init_cmd.py b/cli/python/src/mem0_cli/commands/init_cmd.py index 22de4d7be..9764da388 100644 --- a/cli/python/src/mem0_cli/commands/init_cmd.py +++ b/cli/python/src/mem0_cli/commands/init_cmd.py @@ -182,21 +182,63 @@ def run_init( email: str | None = None, code: str | None = None, force: bool = False, + source: str | None = None, + agent: bool = False, ) -> None: """Interactive setup wizard for mem0 CLI. When both *api_key* and *user_id* are supplied, all prompts are skipped (non-interactive mode). When running in a non-TTY without the required flags, an error message is printed. + + Agent Mode dispatch (no email/api-key flags): + - If existing config has an active API key → reuse (existing_key path). + - Else if any positive agent signal (--agent, --json global, agent env + var, or `agent` flag) → POST /api/v1/auth/agent_mode/ and write config. + - Else fall through to the interactive wizard. + + Claim dispatch: + - If `--email` is set AND existing config has `agent_mode=true`, run the + claim device-flow against the existing key instead of minting a new + email-based key. """ + from mem0_cli.agent_detect import detect_agent_caller + from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_device_flow + from mem0_cli.state import is_agent_mode as _global_agent_mode + from mem0_cli.telemetry import capture_event + + def _fire_init(mode: str, *, claimed: bool = False) -> None: + """Fire cli.init telemetry with M1-M6 properties.""" + props: dict = {"command": "init", "mode": mode} + agent_caller = detect_agent_caller() + if agent_caller: + props["agent_caller"] = agent_caller + if source: + props["signup_source"] = source + if claimed: + props["claimed_agent_mode"] = True + capture_event("cli.init", props) + config = Mem0Config() base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL) + config.platform.base_url = base_url if code and not email: print_error(err_console, "--code requires --email.") raise typer.Exit(1) + # ── Email + existing agent-mode config → claim flow ───────────────── + if email and CONFIG_FILE.exists(): + existing = load_config() + if existing.platform.agent_mode and existing.platform.api_key: + email = email.strip().lower() + _validate_email(email) + print_info(console, f"Claiming Agent Mode account to {email}...") + claim_via_device_flow(existing, email=email) + _fire_init("email", claimed=True) + return + # Warn if an existing config with an API key would be overwritten if not force and CONFIG_FILE.exists(): existing = load_config() @@ -257,6 +299,16 @@ def run_init( console.print() return + # ── Agent Mode auto-bootstrap (no email, no api_key flag) ───────── + # Positive agent signal required: explicit --agent flag (local or global) + # OR a recognized agent env var. Pure "no TTY" alone is NOT enough — pipe + # users would get surprised by a silent shadow signup. + agent_ctx = agent or _global_agent_mode() or (detect_agent_caller() is not None) + if not api_key and not email and agent_ctx: + bootstrap_via_backend(config, source=source) + _fire_init("agent") + return + # ── API key flow (existing) ─────────────────────────────────────── # Non-TTY: resolve defaults so partial flags work in pipelines / CI @@ -265,7 +317,7 @@ def run_init( print_error( err_console, "Non-interactive terminal detected and --api-key is required.", - hint="Run: mem0 init --api-key [--user-id ]", + hint="Run: mem0 init --api-key , --email , or --agent for unattended Agent Mode bootstrap.", ) raise typer.Exit(1) user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" diff --git a/cli/python/src/mem0_cli/config.py b/cli/python/src/mem0_cli/config.py index ebf81fc80..caf231be7 100644 --- a/cli/python/src/mem0_cli/config.py +++ b/cli/python/src/mem0_cli/config.py @@ -28,6 +28,11 @@ class PlatformConfig: api_key: str = "" base_url: str = DEFAULT_BASE_URL user_email: str = "" + # Agent Mode (unclaimed-shadow signup) + agent_mode: bool = False # True while the key is an unclaimed agent-mode key + created_via: str = "" # "agent_mode" | "email" | "api_key" | "existing_key" + claimed_at: str = "" # ISO timestamp once the agent has been claimed by a human + default_user_id: str = "" # `user_` returned by bootstrap; used as auto-default @dataclass @@ -83,6 +88,10 @@ def load_config() -> Mem0Config: config.platform.api_key = plat.get("api_key", "") config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL) config.platform.user_email = plat.get("user_email", "") + config.platform.agent_mode = bool(plat.get("agent_mode", False)) + config.platform.created_via = plat.get("created_via", "") + config.platform.claimed_at = plat.get("claimed_at", "") + config.platform.default_user_id = plat.get("default_user_id", "") defaults = data.get("defaults", {}) config.defaults.user_id = defaults.get("user_id", "") @@ -136,6 +145,10 @@ def save_config(config: Mem0Config) -> None: "api_key": config.platform.api_key, "base_url": config.platform.base_url, "user_email": config.platform.user_email, + "agent_mode": config.platform.agent_mode, + "created_via": config.platform.created_via, + "claimed_at": config.platform.claimed_at, + "default_user_id": config.platform.default_user_id, }, "telemetry": { "anonymous_id": config.telemetry.anonymous_id, diff --git a/cli/python/src/mem0_cli/telemetry.py b/cli/python/src/mem0_cli/telemetry.py index b0fc50fe9..5c8e0e4ff 100644 --- a/cli/python/src/mem0_cli/telemetry.py +++ b/cli/python/src/mem0_cli/telemetry.py @@ -87,7 +87,6 @@ def capture_event( try: from mem0_cli import __version__ from mem0_cli.config import CONFIG_FILE, load_config, save_config - from mem0_cli.state import is_agent_mode config = load_config() distinct_id = pre_resolved_email or _get_distinct_id() @@ -107,6 +106,9 @@ def capture_event( with contextlib.suppress(Exception): save_config(config) + # M4: every cli.* event carries agent_mode based on the config flag + # (unclaimed Agent Mode key). This is the growth-doc property used to + # join init → add → search funnels in PostHog. payload = { "api_key": POSTHOG_API_KEY, "distinct_id": distinct_id, @@ -115,7 +117,7 @@ def capture_event( "source": "CLI", "language": "python", "cli_version": __version__, - "agent_mode": is_agent_mode(), + "agent_mode": bool(config.platform.agent_mode), "python_version": sys.version, "os": sys.platform, "os_version": platform.version(),