diff --git a/README.md b/README.md index a5520c6e3..2de25b889 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgra ## πŸš€ Quickstart Guide +> **Agent Mode β€” for AI agents setting up Mem0 autonomously.** Run `mem0 init --agent --agent-caller --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), replacing `` with your AI agent identity (e.g. `claude-code`, `cursor`, `codex`). This mints an evaluation Mem0 API key in <5 seconds β€” no email, no dashboard, no OTP. If you forgot to pass `--agent-caller`, run `mem0 identify ` after init. Use the key immediately against the Platform or the MCP server. The human owner can claim ownership later with `mem0 init --email `: memories transfer, the same key keeps working, and the agent isn't disrupted. + | | Library | Self-Hosted Server | Cloud Platform | |---|---------|-------------------|----------------| | **Best for** | Testing, prototyping | Teams running on their own infrastructure | Zero-ops production use | diff --git a/cli/cli-spec.json b/cli/cli-spec.json index 4826bd1ad..437bb8e46 100644 --- a/cli/cli-spec.json +++ b/cli/cli-spec.json @@ -503,7 +503,7 @@ }, { "name": "init", - "description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).", + "description": "Setup wizard for mem0 CLI. Supports Agent Mode bootstrap (--agent), email login (--email), or manual API key (--api-key).", "usage": "mem0 init [OPTIONS]", "needsBackend": false, "needsConfig": false, @@ -516,7 +516,9 @@ { "name": "user-id", "flags": ["-u", "--user-id"], "type": "string", "default": null, "help": "Default user ID (skip prompt)." }, { "name": "email", "flags": ["--email"], "type": "string", "default": null, "help": "Login via email verification code." }, { "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." }, - { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." } + { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." }, + { "name": "agent", "flags": ["--agent"], "type": "boolean", "default": false, "help": "Bootstrap an unattended Agent Mode account (no email required)." }, + { "name": "source", "flags": ["--source"], "type": "string", "default": null, "help": "Channel attribution for signup (e.g. github, hn, ph)." } ] }, { diff --git a/cli/node/package.json b/cli/node/package.json index c7182376b..08cdf2cf5 100644 --- a/cli/node/package.json +++ b/cli/node/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/cli", - "version": "0.2.4", + "version": "0.2.5", "description": "The official CLI for mem0 β€” the memory layer for AI agents", "type": "module", "bin": { diff --git a/cli/node/src/agent-detect.ts b/cli/node/src/agent-detect.ts new file mode 100644 index 000000000..197b0c824 --- /dev/null +++ b/cli/node/src/agent-detect.ts @@ -0,0 +1,32 @@ +/** + * Detect whether the CLI is being invoked from inside an AI-agent context. + * + * Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an + * agent runtime env var is present. The return value is a context **trigger + * only** β€” the canonical agent identity is self-declared by the agent via + * `--agent-caller ` (Proof Editor-style) and never sniffed from env + * vars to fill the `agent_caller` field on the APIKey row. + * + * Returns a short name or null. Honest reporting depends on `--agent-caller`; + * this list is just enough to enable the zero-friction auto-bootstrap UX. + */ + +const AGENT_CALLER_ENV: ReadonlyArray = [ + ["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"]], +] as const; + +export function detectAgentCaller(): string | null { + for (const [name, envVars] of AGENT_CALLER_ENV) { + if (envVars.some((v) => process.env[v])) { + return name; + } + } + return null; +} diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 3d7b012ad..57eb51dde 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -3,7 +3,7 @@ */ import type { PlatformConfig } from "../config.js"; -import { isAgentMode } from "../state.js"; +import { captureNotice, isAgentMode } from "../state.js"; import { CLI_VERSION } from "../version.js"; import { APIError, @@ -90,7 +90,39 @@ export class PlatformBackend implements Backend { if (resp.status === 204) { return {}; } - return resp.json(); + + const data = await resp.json(); + + // Pull the unclaimed-Agent-Mode notice out of the body (or the header + // fallback for endpoints returning non-dict / non-dict-leading payloads) + // and stash for end-of-command surfacing. + let notice: string | null = null; + if ( + data && + typeof data === "object" && + !Array.isArray(data) && + "mem0_notice" in data + ) { + notice = (data as Record).mem0_notice as string; + // biome-ignore lint/performance/noDelete: intentional strip so downstream consumers don't see duplicate notice + delete (data as Record).mem0_notice; + } else if ( + Array.isArray(data) && + data.length > 0 && + typeof data[0] === "object" && + data[0] !== null && + "mem0_notice" in data[0] + ) { + notice = (data[0] as Record).mem0_notice as string; + // biome-ignore lint/performance/noDelete: see above. + delete (data[0] as Record).mem0_notice; + } + if (!notice) { + notice = resp.headers.get("X-Mem0-Notice-Message") ?? null; + } + captureNotice(notice); + + return data; } async add( diff --git a/cli/node/src/commands/agent-mode.ts b/cli/node/src/commands/agent-mode.ts new file mode 100644 index 000000000..d606e046d --- /dev/null +++ b/cli/node/src/commands/agent-mode.ts @@ -0,0 +1,285 @@ +/** + * Agent Mode commands β€” bootstrap (unattended signup) and OTP-based claim. + */ + +import readline from "node:readline"; +import { colors, printError, printInfo, printSuccess } from "../branding.js"; +import { type Mem0Config, saveConfig } from "../config.js"; + +const { brand, dim } = colors; + +const SOURCE_HEADERS = { + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "node", +} as const; + +export interface BootstrapEnvelope { + api_key: string; + default_user_id: string; + org_id: string; + project_id: string; + mcp_url?: string; + smoke_test_url?: string; + claim_command?: string; + mem0_notice?: string; +} + +function isValidEnvelope(v: unknown): v is BootstrapEnvelope { + return ( + !!v && + typeof v === "object" && + typeof (v as BootstrapEnvelope).api_key === "string" && + (v as BootstrapEnvelope).api_key.length > 0 && + typeof (v as BootstrapEnvelope).default_user_id === "string" && + (v as BootstrapEnvelope).default_user_id.length > 0 + ); +} + +/** + * POST /api/v1/auth/agent_mode/ and mutate config in place. + * + * @param config - Mem0Config mutated in place with the new platform values. + * @param source - `--source` flag passthrough (analytics tag, free-form). + * @param agentCaller - Self-declared agent identity passed via `--agent-caller` + * (e.g. `claude-code`, `cursor`). May be null when the caller omitted the + * flag; the agent can backfill later via `mem0 identify `. Sent to the + * backend in the request body and saved into `platform.agentCaller` for + * local introspection. + */ +export async function bootstrapViaBackend( + config: Mem0Config, + { + source, + agentCaller, + }: { source?: string | null; agentCaller?: string | null } = {}, +): Promise { + const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace( + /\/+$/, + "", + ); + const body: Record = {}; + if (source) body.source = source; + if (agentCaller) body.agent_caller = agentCaller; + + let resp: Response; + try { + resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/`, { + method: "POST", + headers: { + ...SOURCE_HEADERS, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + } catch (err) { + printError( + `Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + if (resp.status === 429) { + printError("Rate-limited. Try again in a few minutes."); + process.exit(1); + } + if (resp.status === 503) { + printError("Agent Mode is temporarily disabled. Try again later."); + process.exit(1); + } + if (!resp.ok) { + let detail: string = resp.statusText; + try { + const errBody = (await resp.json()) as { + error?: string; + detail?: string; + }; + detail = errBody.error ?? errBody.detail ?? resp.statusText; + } catch { + /* leave detail as statusText */ + } + // Backend's @ratelimit decorator raises PermissionDenied, which DRF + // translates to a generic 403 "You do not have permission to perform + // this action." That's opaque β€” surface it as the rate-limit message + // it actually is. + if (resp.status === 403 && /permission/i.test(detail)) { + printError( + "Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.", + ); + process.exit(1); + } + printError(`Bootstrap failed: ${detail}`); + process.exit(1); + } + + const envelope = (await resp.json()) as BootstrapEnvelope; + if (!isValidEnvelope(envelope)) { + // Defend against partial/malformed backend responses (e.g. {api_key: null}). + // Without this guard, the typed `string` field is silently set to + // undefined/null and persisted, producing confusing downstream errors. + printError( + "Bootstrap response missing required fields β€” please update the CLI.", + ); + process.exit(1); + } + + config.platform.apiKey = envelope.api_key; + config.platform.baseUrl = baseUrl; + config.platform.agentMode = true; + config.platform.createdVia = "agent_mode"; + config.platform.agentCaller = agentCaller ?? ""; + config.platform.claimedAt = ""; + config.platform.defaultUserId = envelope.default_user_id; + // Adopt the slug-derived user_id as the default scope for memory ops. + config.defaults.userId = envelope.default_user_id; + saveConfig(config); + + printSuccess( + `Agent Mode active. Default user_id: ${envelope.default_user_id}`, + ); + if (envelope.mem0_notice) { + console.log(`\n\x1b[33mπŸ”” ${envelope.mem0_notice}\x1b[0m\n`); + } else { + // Fallback for older backends without the unified notice field. + const claimCmd = envelope.claim_command ?? "mem0 init --email "; + console.log(` ${dim(`To claim this account later: ${claimCmd}`)}`); + } + + if (!agentCaller) { + console.log( + ` ${dim("If you are an AI agent, identify yourself: `mem0 identify ` (e.g. claude-code, cursor).")}`, + ); + } +} + +/** + * Claim an existing Agent Mode account via OTP β€” no browser, no polling. + * + * Hits /api/v1/auth/email_code/ to send a verification code, prompts for it + * interactively (or accepts via `code`), then sends it to /verify/ alongside + * `agent_mode_api_key`. Backend's verify_email_code runs upgrade-in-place + * inline and returns the claim result. + */ +export async function claimViaOtp( + config: Mem0Config, + { email, code }: { email: string; code?: string }, +): Promise { + const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace( + /\/+$/, + "", + ); + if (!config.platform.apiKey || !config.platform.agentMode) { + printError( + "This command requires an active Agent Mode config. Run `mem0 init` first.", + ); + process.exit(1); + } + + const rawKey = config.platform.apiKey; + + // Step 1: request OTP (unless --code was supplied) + if (!code) { + const sendResp = await fetch(`${baseUrl}/api/v1/auth/email_code/`, { + method: "POST", + headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + signal: AbortSignal.timeout(30_000), + }); + if (sendResp.status === 429) { + printError("Too many attempts. Try again in a few minutes."); + process.exit(1); + } + if (!sendResp.ok) { + let detail: string = sendResp.statusText; + try { + const errBody = (await sendResp.json()) as { error?: string }; + if (errBody.error) detail = errBody.error; + } catch { + /* leave as statusText */ + } + printError(`Failed to send code: ${detail}`); + process.exit(1); + } + + printSuccess(`Verification code sent to ${email}. Check your inbox.`); + + if (!process.stdin.isTTY) { + printError( + "No --code provided and terminal is non-interactive.", + `Re-run: mem0 init --email ${email} --code `, + ); + process.exit(1); + } + + console.log(); + code = await promptLine(` ${brand("Verification Code")}`); + if (!code) { + printError("Code is required."); + process.exit(1); + } + } + + // Step 2: verify + claim atomically + const verifyResp = await fetch(`${baseUrl}/api/v1/auth/email_code/verify/`, { + method: "POST", + headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + code: code.trim(), + agent_mode_api_key: rawKey, + }), + signal: AbortSignal.timeout(30_000), + }); + + if (!verifyResp.ok) { + let detail: string = verifyResp.statusText; + let errCode = ""; + try { + const errBody = (await verifyResp.json()) as { + error?: string; + code?: string; + }; + if (errBody.error) detail = errBody.error; + if (errBody.code) errCode = errBody.code; + } catch { + /* leave as statusText */ + } + printError(`Claim failed: ${detail}`); + if (errCode === "email_already_claimed") { + console.log( + ` ${dim("Tip: this email already has a Mem0 account. Sign in there and run `mem0 link ` to attach this agent.")}`, + ); + } + process.exit(1); + } + + const claimBody = (await verifyResp.json()) as { + claimed?: boolean; + claimed_at?: string; + }; + if (!claimBody.claimed) { + printError(`Unexpected verify response: ${JSON.stringify(claimBody)}`); + process.exit(1); + } + + config.platform.agentMode = false; + config.platform.claimedAt = claimBody.claimed_at ?? new Date().toISOString(); + config.platform.userEmail = email; + config.platform.createdVia = "email"; + saveConfig(config); + + printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`); +} + +function promptLine(label: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(`${label}: `, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} diff --git a/cli/node/src/commands/identify.ts b/cli/node/src/commands/identify.ts new file mode 100644 index 000000000..978247171 --- /dev/null +++ b/cli/node/src/commands/identify.ts @@ -0,0 +1,75 @@ +/** + * mem0 identify β€” declare which agent owns the current agent-mode key. + * + * Used when `mem0 init --agent` ran without --agent-caller, so the backend + * saved agent_caller=NULL. The agent re-runs `mem0 identify ` to PATCH + * its own row with its real identity. Idempotent. + */ + +import { printError, printSuccess } from "../branding.js"; +import { loadConfig, saveConfig } from "../config.js"; + +const SOURCE_HEADERS = { + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "node", +} as const; + +export async function runIdentify(name: string): Promise { + const config = loadConfig(); + if (!config.platform.apiKey) { + printError("No API key configured. Run `mem0 init --agent` first."); + process.exit(1); + } + if (!config.platform.agentMode) { + printError("This command only works on unclaimed agent-mode keys."); + process.exit(1); + } + + const clean = (name ?? "").trim(); + if (!clean) { + printError("Agent name is required."); + process.exit(1); + } + + const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace( + /\/+$/, + "", + ); + + let resp: Response; + try { + resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/caller/`, { + method: "PATCH", + headers: { + ...SOURCE_HEADERS, + Authorization: `Token ${config.platform.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ agent_caller: clean }), + signal: AbortSignal.timeout(30_000), + }); + } catch (err) { + printError( + `Network error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + if (!resp.ok) { + let detail: string = resp.statusText; + try { + const body = (await resp.json()) as { error?: string }; + if (body.error) detail = body.error; + } catch { + /* leave as statusText */ + } + printError(`Identify failed: ${detail}`); + process.exit(1); + } + + const body = (await resp.json()) as { agent_caller?: string }; + const canonical = body.agent_caller ?? clean; + config.platform.agentCaller = canonical; + saveConfig(config); + printSuccess(`Identified as ${canonical}.`); +} diff --git a/cli/node/src/commands/init.ts b/cli/node/src/commands/init.ts index 930b4818f..a6e427deb 100644 --- a/cli/node/src/commands/init.ts +++ b/cli/node/src/commands/init.ts @@ -21,6 +21,8 @@ import { redactKey, saveConfig, } from "../config.js"; +import { formatJsonEnvelope } from "../output.js"; +import { isAgentMode } from "../state.js"; const { brand, dim } = colors; @@ -33,6 +35,65 @@ function validateEmail(email: string): void { } } +/** @internal β€” exported for unit tests. */ +export async function pingKey( + apiKey: string, + baseUrl: string, + timeoutMs = 5000, +): Promise { + // Returns false ONLY on a definitive "invalid key" signal (HTTP 401/403). + // Network errors, timeouts, and 5xx responses return true so we prefer + // reusing an existing key over silently minting a new shadow on a transient + // blip (which would also clobber config + plugin-sync targets). + try { + const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/ping/`, { + headers: { Authorization: `Token ${apiKey}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + return resp.status !== 401 && resp.status !== 403; + } catch { + return true; // unknown β€” prefer reuse + } +} + +async function maybeIdentify( + key: string, + baseUrl: string, + agentCaller: string | undefined, +): Promise { + // Best-effort PATCH agent_caller when --agent-caller is supplied on a + // reused key. Silent no-op on any failure β€” reuse must not break. + if (!agentCaller) return; + try { + const resp = await fetch( + `${baseUrl.replace(/\/+$/, "")}/api/v1/auth/agent_mode/caller/`, + { + method: "PATCH", + headers: { + Authorization: `Token ${key}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ agent_caller: agentCaller }), + signal: AbortSignal.timeout(10_000), + }, + ); + if (resp.ok) { + try { + const body = (await resp.json()) as { agent_caller?: string }; + if (fs.existsSync(CONFIG_FILE)) { + const cfg = loadConfig(); + cfg.platform.agentCaller = body.agent_caller ?? agentCaller; + saveConfig(cfg); + } + } catch { + /* swallow β€” best effort */ + } + } + } catch { + /* swallow β€” best effort */ + } +} + async function emailLogin( email: string, code: string | undefined, @@ -196,6 +257,7 @@ async function setupPlatform(config: Mem0Config): Promise { process.exit(1); } config.platform.apiKey = apiKey; + config.platform.createdVia = "api_key"; } async function setupDefaults(config: Mem0Config): Promise { @@ -249,14 +311,35 @@ export async function runInit( email?: string; code?: string; force?: boolean; + agent?: boolean; + source?: string; + agentCaller?: string; } = {}, ): Promise { + const { detectAgentCaller } = await import("../agent-detect.js"); + const { bootstrapViaBackend, claimViaOtp } = await import("./agent-mode.js"); + const { isAgentMode } = await import("../state.js"); + const { captureEvent } = await import("../telemetry.js"); + + const fireInit = ( + mode: "agent" | "email" | "api_key" | "existing_key", + claimed = false, + ) => { + const props: Record = { command: "init", mode }; + // Self-declared via --agent-caller; not sniffed from env vars. + if (opts.agentCaller) props.agent_caller = opts.agentCaller; + if (opts.source) props.signup_source = opts.source; + if (claimed) props.claimed_agent_mode = true; + captureEvent("cli.init", props); + }; + const config = createDefaultConfig(); const savedConfig = loadConfig(); const baseUrl = process.env.MEM0_BASE_URL || savedConfig.platform.baseUrl || DEFAULT_BASE_URL; + config.platform.baseUrl = baseUrl; // Guards if (opts.code && !opts.email) { @@ -268,6 +351,84 @@ export async function runInit( process.exit(1); } + // ── Claim flow: --email against an existing agent-mode config ─────────── + if ( + opts.email && + fs.existsSync(CONFIG_FILE) && + savedConfig.platform.agentMode && + savedConfig.platform.apiKey + ) { + const email = opts.email.trim().toLowerCase(); + validateEmail(email); + printInfo(`Claiming Agent Mode account to ${email}...`); + await claimViaOtp(savedConfig, { email, code: opts.code }); + fireInit("email", true); + return; + } + + // ── Agent Mode path runs BEFORE the existing-config guard ────────────── + // Rule 1/2 will REUSE a valid existing key (not overwrite), so we must + // short-circuit before the guard prompts the user about overwriting. + // Rule 3 only mints when there's no valid key to reuse β€” in that case + // overwriting is what the user wants. + const agentCtx = + opts.agent === true || isAgentMode() || detectAgentCaller() !== null; + if (!opts.apiKey && !opts.email && agentCtx) { + const emitReuseEnvelope = (source: "env" | "config") => { + if (isAgentMode()) { + formatJsonEnvelope({ + command: "init", + data: { + api_key_saved: false, + api_key_source: source, + agent_mode: false, + message: + "Existing Mem0 API key found and reused. No Agent Mode key was created.", + }, + }); + } else { + printSuccess( + source === "env" + ? "Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted." + : "Existing API key in config is valid; reusing it. No new Agent Mode key was minted.", + ); + } + }; + // Rule 1: env MEM0_API_KEY valid β†’ reuse, no new key. + const envKey = (process.env.MEM0_API_KEY || "").trim(); + if (envKey && (await pingKey(envKey, baseUrl))) { + await maybeIdentify(envKey, baseUrl, opts.agentCaller); + emitReuseEnvelope("env"); + fireInit("existing_key"); + return; + } + // Rule 2: existing config api_key valid β†’ reuse. + if ( + savedConfig.platform.apiKey && + (await pingKey(savedConfig.platform.apiKey, baseUrl)) + ) { + await maybeIdentify( + savedConfig.platform.apiKey, + baseUrl, + opts.agentCaller, + ); + emitReuseEnvelope("config"); + fireInit("existing_key"); + return; + } + // Rule 3: mint a fresh shadow (no valid key to reuse). + // agent_caller is self-declared via --agent-caller (Proof Editor-style), + // not derived from env-var sniffing. detectAgentCaller() above is still + // used as a context trigger (does this look like an agent?) but never + // to fill identity. + await bootstrapViaBackend(config, { + source: opts.source ?? null, + agentCaller: opts.agentCaller ?? null, + }); + fireInit("agent"); + return; + } + // Warn if an existing config with an API key would be overwritten if ( !opts.force && @@ -324,6 +485,7 @@ export async function runInit( config.platform.apiKey = apiKeyVal; config.platform.baseUrl = baseUrl; config.platform.userEmail = email; + config.platform.createdVia = "email"; config.defaults.userId = opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli"; @@ -339,13 +501,15 @@ export async function runInit( } // ── API key flow ────────────────────────────────────────────────────────── + // (Agent Mode branch runs earlier β€” see above, before the existing-config + // guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.) // Non-TTY: resolve defaults so partial flags work in pipelines / CI if (!process.stdin.isTTY) { if (!opts.apiKey) { printError( "Non-interactive terminal detected and --api-key is required.", - "Usage: mem0 init --api-key [--user-id ]", + "Usage: mem0 init --api-key , --email , or --agent for unattended Agent Mode bootstrap.", ); process.exit(1); } @@ -356,6 +520,7 @@ export async function runInit( // Non-interactive: both flags provided if (opts.apiKey && opts.userId) { config.platform.apiKey = opts.apiKey; + config.platform.createdVia = "api_key"; config.defaults.userId = opts.userId; await validatePlatform(config); saveConfig(config); @@ -403,6 +568,7 @@ export async function runInit( config.platform.apiKey = apiKeyVal; config.platform.baseUrl = baseUrl; config.platform.userEmail = email; + config.platform.createdVia = "email"; config.defaults.userId = opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli"; diff --git a/cli/node/src/config.ts b/cli/node/src/config.ts index 29a0d7157..13b8ba227 100644 --- a/cli/node/src/config.ts +++ b/cli/node/src/config.ts @@ -21,6 +21,12 @@ export interface PlatformConfig { apiKey: string; baseUrl: string; userEmail: string; + // Agent Mode (unclaimed-shadow signup) + agentMode: boolean; // true while the key is an unclaimed agent-mode key + createdVia: string; // "agent_mode" | "email" | "api_key" | "existing_key" + agentCaller: string; // canonical agent name when createdVia === "agent_mode" (e.g. "claude-code") + claimedAt: string; // ISO timestamp once the agent has been claimed + defaultUserId: string; // `user_` returned by bootstrap; auto-default scope } export interface DefaultsConfig { @@ -54,6 +60,11 @@ export function createDefaultConfig(): Mem0Config { apiKey: "", baseUrl: DEFAULT_BASE_URL, userEmail: "", + agentMode: false, + createdVia: "", + agentCaller: "", + claimedAt: "", + defaultUserId: "", }, telemetry: { anonymousId: "", @@ -79,6 +90,11 @@ export function loadConfig(): Mem0Config { config.platform.apiKey = plat.api_key ?? ""; config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL; config.platform.userEmail = plat.user_email ?? ""; + config.platform.agentMode = Boolean(plat.agent_mode ?? false); + config.platform.createdVia = plat.created_via ?? ""; + config.platform.agentCaller = plat.agent_caller ?? ""; + config.platform.claimedAt = plat.claimed_at ?? ""; + config.platform.defaultUserId = plat.default_user_id ?? ""; const defaults = data.defaults ?? {}; config.defaults.userId = defaults.user_id ?? ""; @@ -118,6 +134,11 @@ export function saveConfig(config: Mem0Config): void { api_key: config.platform.apiKey, base_url: config.platform.baseUrl, user_email: config.platform.userEmail, + agent_mode: config.platform.agentMode, + created_via: config.platform.createdVia, + agent_caller: config.platform.agentCaller, + claimed_at: config.platform.claimedAt, + default_user_id: config.platform.defaultUserId, }, telemetry: { anonymous_id: config.telemetry.anonymousId, @@ -126,6 +147,20 @@ export function saveConfig(config: Mem0Config): void { fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2)); fs.chmodSync(CONFIG_FILE, 0o600); + + // Propagate api_key to ecosystem touchpoints (Claude plugin env injection, + // shell rc exports). Idempotent β€” updates only EXISTING entries; never + // creates new ones. Best-effort: errors swallowed so config.json is + // always authoritative, never blocked by plugin-state issues. + if (config.platform.apiKey) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { syncApiKey } = require("./plugin-sync.js"); + syncApiKey(config.platform.apiKey); + } catch { + /* swallow */ + } + } } export function redactKey(key: string): string { diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index 30db1209b..219c63b1e 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -13,7 +13,12 @@ import { colors, printError, printWarning } from "./branding.js"; import type { Mem0Config } from "./config.js"; import { loadConfig, saveConfig } from "./config.js"; import { richFormatHelp } from "./help.js"; -import { setAgentMode } from "./state.js"; +import { + isAgentMode, + setAgentMode, + setCurrentCommand, + takeNotice, +} from "./state.js"; import { captureEvent } from "./telemetry.js"; import { CLI_VERSION } from "./version.js"; @@ -141,6 +146,11 @@ program .description( `β—† Mem0 CLI v${CLI_VERSION} Β· Node.js SDK\n\nThe Memory Layer for AI Agents`, ) + // Positional options: flags AFTER a subcommand name belong to that + // subcommand, not the global program. Without this, `mem0 init --agent` + // routes `--agent` to the program-level alias (for --json) and init's own + // `--agent` (Agent Mode bootstrap) silently never fires. + .enablePositionalOptions() .option("--version", "Show version and exit.") .on("option:version", () => { console.log(` ${colors.brand("β—† Mem0")} CLI v${CLI_VERSION}`); @@ -149,7 +159,7 @@ program .option("--json", "Output as JSON for agent/programmatic use.") .option( "--agent", - "Output as JSON for agent/programmatic use. (alias: --json)", + "Output as JSON for agent/programmatic use. (alias: --json) Place BEFORE the subcommand: `mem0 --agent `. On `init`, `mem0 init --agent` is the Agent Mode bootstrap flag instead.", ) .usage(" [options]") .helpOption("--help", "Show this message and exit.") @@ -166,6 +176,14 @@ program.hook("preAction", (_thisCommand, actionCommand) => { parentName && parentName !== "mem0" ? `${parentName}.${commandName}` : commandName; + // Stash the active command name in shared state so the JSON + // error envelope (printError) can report which command failed + // instead of an empty `"command": ""` field. + setCurrentCommand(fullCommand); + // init fires its own telemetry from runInit with full M1-M6 props + // (mode/agent_caller/signup_source/claimed_agent_mode); skip the + // auto-fire here so we don't double-count. + if (fullCommand === "init") return; const isAgent = !!(program.opts().json || program.opts().agent); captureEvent( `cli.${fullCommand}`, @@ -193,11 +211,32 @@ program "Verification code (use with --email for non-interactive login).", ) .option("--force", "Overwrite existing config without confirmation.", false) + .option( + "--agent", + "Bootstrap an unattended Agent Mode account (no email required).", + false, + ) + .option( + "--source ", + "Channel attribution for signup (e.g. github, hn, ph).", + ) + .option( + "--agent-caller ", + "Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.", + ) + // Accept `--json` at the init level too so the PRD-documented form + // `mem0 init --agent --json` works without requiring users to move it + // before the subcommand. Effect is identical to the global `--json`: + // flip agent-mode output state. + .option("--json", "Output as JSON (alias for global `--json`).", false) .addHelpText( "after", - "\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456", + "\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456\n $ mem0 init --agent # Bootstrap an Agent Mode account (unattended)\n $ mem0 init --email you@example.com # Claims an existing Agent Mode key when one is present", ) .action(async (opts) => { + // `--json` at init level mirrors the global flag β€” flip agent_mode + // state so downstream formatters use JSON envelopes. + if (opts.json) setAgentMode(true); const { runInit } = await import("./commands/init.js"); await runInit({ apiKey: opts.apiKey, @@ -205,9 +244,24 @@ program email: opts.email, code: opts.code, force: opts.force, + agent: opts.agent, + source: opts.source, + agentCaller: opts.agentCaller, }); }); +// ── Setup: identify (post-bootstrap agent self-tag) ────────────────────── + +program + .command("identify ") + .description( + "Tag your active Agent Mode key with the AI agent that's using it (e.g. claude-code, cursor).", + ) + .action(async (name: string) => { + const { runIdentify } = await import("./commands/identify.js"); + await runIdentify(name); + }); + // ── Memory: add ─────────────────────────────────────────────────────────── program @@ -769,4 +823,16 @@ program // ── Entrypoint ──────────────────────────────────────────────────────────── -program.parse(); +// Surface any unclaimed Agent Mode notice once per command, after the primary +// output. In JSON/agent mode the notice is folded into the envelope by +// formatJsonEnvelope, so skip the stderr banner there to avoid duplication. +function surfaceNotice(): void { + const notice = takeNotice(); + if (notice && !isAgentMode()) { + process.stderr.write(`\n\x1b[33mπŸ”” ${notice}\x1b[0m\n\n`); + } +} + +program.parseAsync().finally(() => { + surfaceNotice(); +}); diff --git a/cli/node/src/output.ts b/cli/node/src/output.ts index 83cfa98b1..fa14849e7 100644 --- a/cli/node/src/output.ts +++ b/cli/node/src/output.ts @@ -5,6 +5,7 @@ import boxen from "boxen"; import Table from "cli-table3"; import { colors, sym } from "./branding.js"; +import { takeNotice } from "./state.js"; const { brand, accent, success, error: errorColor, dim } = colors; @@ -244,6 +245,15 @@ export function formatJsonEnvelope(opts: { if (opts.count !== undefined) envelope.count = opts.count; if (opts.error) envelope.error = opts.error; envelope.data = opts.data; + + // If the platform flagged this as an unclaimed Agent Mode account, surface + // the notice inside the JSON envelope so an agent consuming the output + // sees it without needing to inspect HTTP headers. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { takeNotice } = require("./state.js"); + const notice = takeNotice(); + if (notice) envelope.mem0_notice = notice; + console.log(JSON.stringify(envelope, null, 2)); } @@ -356,6 +366,12 @@ export function formatAgentEnvelope(opts: { } if (opts.count !== undefined) envelope.count = opts.count; envelope.data = sanitizeAgentData(opts.command, opts.data); + + // Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an + // agent reading the JSON output sees it without inspecting HTTP headers. + const notice = takeNotice(); + if (notice) envelope.mem0_notice = notice; + console.log(JSON.stringify(envelope, null, 2)); } diff --git a/cli/node/src/plugin-sync.ts b/cli/node/src/plugin-sync.ts new file mode 100644 index 000000000..0dcf8f71b --- /dev/null +++ b/cli/node/src/plugin-sync.ts @@ -0,0 +1,120 @@ +/** + * Sync the active Mem0 API key into other ecosystem touchpoints. + * + * Why: the CLI canonical state is ~/.mem0/config.json. MCP servers + * (Claude Code plugin, Codex plugin) read MEM0_API_KEY from env or + * their own config files. Without a sync, agent-mode bootstrap mints a + * new key into config.json but the plugin's MCP keeps using the old + * key from env β€” silent surprise. + * + * Design: + * - Update ONLY entries that already exist; never create new ones + * - Preserve surrounding content, formatting, other keys + * - Atomic writes (tmp + rename) so a crash mid-write doesn't corrupt + * - Idempotent β€” re-running with the same key is a no-op + * + * Targets: + * - ~/.claude/settings.json::env::MEM0_API_KEY (Claude Code env injection) + * - ~/.zshrc / ~/.bashrc `export MEM0_API_KEY="..."` lines + * + * Out of scope: Codex / Cursor MCP configs and the plugin's own + * /.api_key file (plugin-managed, different schema). + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const CLAUDE_SETTINGS = path.join(os.homedir(), ".claude", "settings.json"); +const SHELL_RCS = [ + path.join(os.homedir(), ".zshrc"), + path.join(os.homedir(), ".bashrc"), + path.join(os.homedir(), ".bash_profile"), +]; + +// Use [ \t]* (not \s*) so a trailing newline at end-of-file is preserved +// when the MEM0_API_KEY export is the last line of the rc file. +const RC_LINE_RE = + /^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["']?)([^"'\n]*)(["']?)[ \t]*$/m; + +export function syncApiKey(apiKey: string): string[] { + if (!apiKey) return []; + const updated: string[] = []; + if (updateClaudeSettings(CLAUDE_SETTINGS, apiKey)) { + updated.push(CLAUDE_SETTINGS); + } + for (const rc of SHELL_RCS) { + if (updateShellRc(rc, apiKey)) updated.push(rc); + } + return updated; +} + +/** @internal β€” exported for unit tests; consumers should use {@link syncApiKey}. */ +export function updateClaudeSettings( + filePath: string, + apiKey: string, +): boolean { + if (!fs.existsSync(filePath)) return false; + let raw: string; + let data: Record; + try { + raw = fs.readFileSync(filePath, "utf-8"); + data = JSON.parse(raw); + } catch { + return false; + } + const env = data.env; + if (!env || typeof env !== "object" || !("MEM0_API_KEY" in env)) { + return false; // no existing entry β€” don't create one + } + const envObj = env as Record; + if (envObj.MEM0_API_KEY === apiKey) return false; // already in sync + envObj.MEM0_API_KEY = apiKey; + atomicWriteText(filePath, `${JSON.stringify(data, null, 2)}\n`); + return true; +} + +/** @internal β€” exported for unit tests; consumers should use {@link syncApiKey}. */ +export function updateShellRc(filePath: string, apiKey: string): boolean { + if (!fs.existsSync(filePath)) return false; + let text: string; + try { + text = fs.readFileSync(filePath, "utf-8"); + } catch { + return false; + } + const match = text.match(RC_LINE_RE); + if (!match) return false; // no existing line + if (match[3] === apiKey) return false; + const newText = text.replace( + RC_LINE_RE, + (_full, prefix) => `${prefix}"${apiKey}"`, + ); + atomicWriteText(filePath, newText); + return true; +} + +function atomicWriteText(filePath: string, content: string): void { + const dir = path.dirname(filePath); + const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`); + try { + fs.writeFileSync(tmp, content, "utf-8"); + // Preserve permissions if original existed. + if (fs.existsSync(filePath)) { + try { + const mode = fs.statSync(filePath).mode & 0o777; + fs.chmodSync(tmp, mode); + } catch { + /* best-effort */ + } + } + fs.renameSync(tmp, filePath); + } catch (err) { + try { + fs.unlinkSync(tmp); + } catch { + /* ignore */ + } + throw err; + } +} diff --git a/cli/node/src/state.ts b/cli/node/src/state.ts index a654c5d8e..724068925 100644 --- a/cli/node/src/state.ts +++ b/cli/node/src/state.ts @@ -5,6 +5,7 @@ let _agentMode = false; let _currentCommand = ""; +let _pendingNotice = ""; export function isAgentMode(): boolean { return _agentMode; @@ -21,3 +22,19 @@ export function getCurrentCommand(): string { export function setCurrentCommand(name: string): void { _currentCommand = name; } + +/** + * Stash a Mem0 backend notice (Agent Mode unclaimed reminder) for end-of- + * command surfacing. Called from the platform backend after each response so + * the notice prints once per command regardless of how many sub-requests + * fired. Last-write-wins is fine β€” the message text is identical. + */ +export function captureNotice(notice: string | null | undefined): void { + if (notice) _pendingNotice = notice; +} + +export function takeNotice(): string { + const msg = _pendingNotice; + _pendingNotice = ""; + return msg; +} diff --git a/cli/node/src/telemetry.ts b/cli/node/src/telemetry.ts index 5828c759f..46a6a76fe 100644 --- a/cli/node/src/telemetry.ts +++ b/cli/node/src/telemetry.ts @@ -115,6 +115,9 @@ export function captureEvent( } } + // 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. const payload = { api_key: POSTHOG_API_KEY, distinct_id: distinctId, @@ -123,6 +126,7 @@ export function captureEvent( source: "CLI", language: "node", cli_version: CLI_VERSION, + agent_mode: Boolean(config.platform.agentMode), node_version: process.version, os: process.platform, ...properties, diff --git a/cli/node/tests/agent-mode.test.ts b/cli/node/tests/agent-mode.test.ts new file mode 100644 index 000000000..79e3203ae --- /dev/null +++ b/cli/node/tests/agent-mode.test.ts @@ -0,0 +1,141 @@ +/** + * Parity tests for `mem0 init --agent` (Agent Mode bootstrap). + * + * Mirror of `cli/python/tests/test_agent_mode.py` β€” both files MUST stay + * in sync so that the Python and Node CLIs expose an identical surface + * for the Agent Mode entrypoint. If you add a flag here, add the same + * assertion on the Python side (and vice versa). + * + * Network-bound bootstrap is covered by the platform-side E2E suite + * (`backend/tests/e2e/test_05_agent_mode.py`); these tests only verify + * the CLI surface that ships in the binary. + */ + +import { describe, it, expect } from "vitest"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function run( + args: string[], + opts: { home?: string; env?: Record } = {}, +): { stdout: string; stderr: string; exitCode: number } { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith("MEM0_")) delete env[key]; + } + if (opts.home) env.HOME = opts.home; + if (opts.env) Object.assign(env, opts.env); + + try { + const stdout = execSync(`npx tsx src/index.ts ${args.join(" ")}`, { + cwd: path.join(__dirname, ".."), + env, + encoding: "utf-8", + timeout: 15000, + }); + return { stdout, stderr: "", exitCode: 0 }; + } catch (e: any) { + return { + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + exitCode: e.status ?? 1, + }; + } +} + +function cleanHome(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-")); +} + +describe("init flag surface", () => { + it("init --help lists --agent", () => { + const result = run(["init", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--agent"); + }); + + it("init --help describes Agent Mode", () => { + const result = run(["init", "--help"]); + expect(result.exitCode).toBe(0); + // Description must mention what --agent actually does so an agent + // reading the help can self-discover the bootstrap entrypoint. + expect( + result.stdout.includes("Agent Mode") || + result.stdout.toLowerCase().includes("unattended"), + ).toBe(true); + }); + + it("init --help lists --source", () => { + const result = run(["init", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--source"); + }); + + it("init --help lists --email and --code", () => { + const result = run(["init", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--email"); + expect(result.stdout).toContain("--code"); + }); +}); + +describe("argv preprocessing β€” --agent reaches init subcommand", () => { + // Regression for the bug where the global --agent JSON-alias swallowed + // the init-level --agent flag, making `mem0 init --agent` behave like + // the plain interactive wizard. + + it("init --agent triggers bootstrap branch (not the wizard)", () => { + const home = cleanHome(); + const result = run(["init", "--agent"], { + home, + env: { + MEM0_BASE_URL: "http://127.0.0.1:1", // blackhole + FORCE_COLOR: "0", + }, + }); + const combined = (result.stdout + result.stderr).toLowerCase(); + // Either bootstrap-attempt error, or a connection/network error β€” + // both prove the --agent path executed (the wizard would prompt for + // input and succeed/hang, not surface a network error). + expect( + combined.includes("agent") || + combined.includes("connect") || + combined.includes("network") || + combined.includes("fetch") || + combined.includes("bootstrap"), + ).toBe(true); + fs.rmSync(home, { recursive: true, force: true }); + }); +}); + +describe("JSON envelope on network failure", () => { + it("init --agent --json does not leak a stack trace when backend is unreachable", () => { + const home = cleanHome(); + const result = run(["init", "--agent", "--json"], { + home, + env: { + MEM0_BASE_URL: "http://127.0.0.1:1", + FORCE_COLOR: "0", + }, + }); + const combined = result.stdout + result.stderr; + // No raw Node stack should escape the agent-mode handler. + expect(combined).not.toMatch(/at \w+\s*\(.+\.ts:\d+/); + expect(combined).not.toContain("UnhandledPromiseRejection"); + expect(result.exitCode).not.toBe(0); + fs.rmSync(home, { recursive: true, force: true }); + }); +}); + +describe("top-level help lists init", () => { + // `mem0 --help` must list `init` so agents walking the top-level help + // can discover the Agent Mode entrypoint without prior knowledge. + + it("--help lists init", () => { + const result = run(["--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("init"); + }); +}); diff --git a/cli/node/tests/init-internals.test.ts b/cli/node/tests/init-internals.test.ts new file mode 100644 index 000000000..7abe2d9bf --- /dev/null +++ b/cli/node/tests/init-internals.test.ts @@ -0,0 +1,168 @@ +/** + * Unit tests for init internals β€” decision tree primitives + plugin sync. + * + * Mirror of `cli/python/tests/test_init_internals.py`. Both files MUST stay + * in sync β€” if you add a behavioral assertion here, mirror it on the Python + * side and vice versa. + * + * - `pingKey` must NOT treat network errors as "invalid key" (else a VPN + * flap silently mints a new shadow over a working key). + * - `plugin_sync` must only update entries that already exist, preserve + * trailing newlines, and never mangle other lines. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { pingKey } from "../src/commands/init.js"; +import { updateClaudeSettings, updateShellRc } from "../src/plugin-sync.js"; + +// ── pingKey ────────────────────────────────────────────────────────────── + +describe("pingKey β€” network vs auth distinction", () => { + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; + vi.restoreAllMocks(); + }); + + it("returns true for 200", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 } as Response); + await expect(pingKey("k", "http://x")).resolves.toBe(true); + }); + + it("returns false for 401 (definitively invalid)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 } as Response); + await expect(pingKey("k", "http://x")).resolves.toBe(false); + }); + + it("returns false for 403 (definitively invalid)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ status: 403 } as Response); + await expect(pingKey("k", "http://x")).resolves.toBe(false); + }); + + it("returns true for 5xx (transient upstream β€” prefer reuse)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ status: 503 } as Response); + await expect(pingKey("k", "http://x")).resolves.toBe(true); + }); + + it("returns true on network error (prefer reuse over re-mint)", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + await expect(pingKey("k", "http://x")).resolves.toBe(true); + }); + + it("returns true on timeout (prefer reuse)", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("aborted")); + await expect(pingKey("k", "http://x")).resolves.toBe(true); + }); +}); + +// ── updateShellRc ──────────────────────────────────────────────────────── + +describe("updateShellRc β€” exists-only contract", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("updates existing export and preserves trailing newline", () => { + const rc = path.join(tmpDir, ".zshrc"); + fs.writeFileSync(rc, 'export MEM0_API_KEY="old"\n'); + expect(updateShellRc(rc, "newkey")).toBe(true); + expect(fs.readFileSync(rc, "utf-8")).toBe('export MEM0_API_KEY="newkey"\n'); + }); + + it("does NOT create a new export when none exists", () => { + const rc = path.join(tmpDir, ".zshrc"); + fs.writeFileSync(rc, "alias ll='ls -la'\n"); + expect(updateShellRc(rc, "newkey")).toBe(false); + expect(fs.readFileSync(rc, "utf-8")).toBe("alias ll='ls -la'\n"); + }); + + it("preserves surrounding content", () => { + const rc = path.join(tmpDir, ".zshrc"); + const original = + "# my zshrc\n" + + "alias ll='ls -la'\n" + + "export MEM0_API_KEY='old'\n" + + "export OTHER=keepme\n"; + fs.writeFileSync(rc, original); + updateShellRc(rc, "newkey"); + const after = fs.readFileSync(rc, "utf-8"); + expect(after).toContain("alias ll='ls -la'\n"); + expect(after).toContain("export OTHER=keepme\n"); + expect(after).toContain("# my zshrc\n"); + expect(after).toContain('export MEM0_API_KEY="newkey"\n'); + }); + + it("is idempotent when value already matches", () => { + const rc = path.join(tmpDir, ".zshrc"); + fs.writeFileSync(rc, 'export MEM0_API_KEY="same"\n'); + expect(updateShellRc(rc, "same")).toBe(false); + }); + + it("is a no-op for missing files", () => { + const rc = path.join(tmpDir, ".zshrc"); // does not exist + expect(updateShellRc(rc, "x")).toBe(false); + }); +}); + +// ── updateClaudeSettings ───────────────────────────────────────────────── + +describe("updateClaudeSettings β€” never creates entries", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("does not create env block when none exists", () => { + const settings = path.join(tmpDir, "settings.json"); + fs.writeFileSync(settings, JSON.stringify({ otherKey: 1 })); + expect(updateClaudeSettings(settings, "newkey")).toBe(false); + expect(JSON.parse(fs.readFileSync(settings, "utf-8"))).toEqual({ + otherKey: 1, + }); + }); + + it("does not create MEM0_API_KEY entry in existing env block", () => { + const settings = path.join(tmpDir, "settings.json"); + fs.writeFileSync(settings, JSON.stringify({ env: { OTHER_KEY: "x" } })); + expect(updateClaudeSettings(settings, "newkey")).toBe(false); + }); + + it("updates existing entry and preserves siblings", () => { + const settings = path.join(tmpDir, "settings.json"); + fs.writeFileSync( + settings, + JSON.stringify({ env: { MEM0_API_KEY: "old", OTHER: "y" } }, null, 2), + ); + expect(updateClaudeSettings(settings, "fresh")).toBe(true); + const data = JSON.parse(fs.readFileSync(settings, "utf-8")); + expect(data.env.MEM0_API_KEY).toBe("fresh"); + expect(data.env.OTHER).toBe("y"); + }); + + it("is idempotent when value already matches", () => { + const settings = path.join(tmpDir, "settings.json"); + fs.writeFileSync( + settings, + JSON.stringify({ env: { MEM0_API_KEY: "same" } }), + ); + expect(updateClaudeSettings(settings, "same")).toBe(false); + }); + + it("is a no-op for malformed JSON", () => { + const settings = path.join(tmpDir, "settings.json"); + fs.writeFileSync(settings, "{ this is not json"); + expect(updateClaudeSettings(settings, "x")).toBe(false); + }); +}); diff --git a/cli/python/pyproject.toml b/cli/python/pyproject.toml index 66176e676..9f9644a50 100644 --- a/cli/python/pyproject.toml +++ b/cli/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0-cli" -version = "0.2.4" +version = "0.2.5" description = "The official CLI for mem0 β€” the memory layer for AI agents" readme = "README.md" license = "Apache-2.0" 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..3223567a2 --- /dev/null +++ b/cli/python/src/mem0_cli/agent_detect.py @@ -0,0 +1,36 @@ +"""Detect whether the CLI is being invoked from inside an AI-agent context. + +Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an +agent runtime env var is present. The return value is a context **trigger +only** β€” the canonical agent identity is self-declared by the agent via +``--agent-caller `` (Proof Editor-style) and never sniffed from env +vars to fill the ``agent_caller`` field on the APIKey row. + +Returns a short name or None. The list is curated, not exhaustive β€” env +vars we don't recognise fall through to None (caller treated as +non-agent). Honest reporting depends on ``--agent-caller``; this list is +just enough to enable the zero-friction auto-bootstrap UX. +""" + +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..b115164e9 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -237,6 +237,14 @@ def main_callback( cmd_version() raise typer.Exit() if ctx.invoked_subcommand: + # Stash the active subcommand name so the JSON error envelope + # (print_error in agent mode) can report which command failed + # instead of an empty `"command": ""` field. + from mem0_cli.state import set_current_command + + set_current_command(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 +859,19 @@ 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).", + ), + agent_caller: str | None = typer.Option( + None, + "--agent-caller", + help="Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.", + ), ) -> None: """Interactive setup wizard for mem0 CLI. @@ -859,10 +880,38 @@ 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 --agent-caller claude-code # AI agent self-identifies on Agent Mode bootstrap + 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, + agent_caller=agent_caller, + ) + + +@app.command(rich_help_panel="Setup") +def identify( + name: str = typer.Argument(..., help="Agent identity (e.g. claude-code, cursor, my-bot)."), +) -> None: + """Tag your active Agent Mode key with the AI agent that's using it. + + Run this once after `mem0 init --agent` if you didn't pass --agent-caller. + Idempotent β€” re-running just overwrites the value. + + Example: + mem0 identify claude-code + """ + from mem0_cli.commands.identify_cmd import run_identify + + run_identify(name) # (entity_app registered at module level, below sub-group definitions) @@ -1198,11 +1247,28 @@ def main() -> None: import sys # Allow --json/--agent anywhere in the command line (not just before subcommand). - _json_flags = {"--json", "--agent"} - if any(a in _json_flags for a in sys.argv[1:]): + # Special case: `mem0 init --agent` is a subcommand flag (Agent Mode bootstrap) + # consumed by init_cmd, not a global JSON-output toggle β€” leave it in argv. + argv_rest = sys.argv[1:] + is_init = "init" in argv_rest + _global_flags = {"--json"} if is_init else {"--json", "--agent"} + if any(a in _global_flags for a in argv_rest): from mem0_cli.state import set_agent_mode set_agent_mode(True) - sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags] + sys.argv = [sys.argv[0]] + [a for a in argv_rest if a not in _global_flags] - app() + try: + app() + finally: + # Surface any unclaimed Agent Mode notice once per command, after the + # primary output. In JSON/agent mode the notice is folded into the + # envelope by format_json_envelope, so skip the stderr banner there + # to avoid duplicate output. + from mem0_cli.state import is_agent_mode, take_notice + + notice = take_notice() + if notice and not is_agent_mode(): + from rich.console import Console + + Console(stderr=True).print(f"\n[yellow]πŸ”” {notice}[/yellow]\n") diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index da1cb47f7..c1a908053 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -30,7 +30,7 @@ class PlatformBackend(Backend): ) def _request(self, method: str, path: str, **kwargs: Any) -> Any: - from mem0_cli.state import is_agent_mode + from mem0_cli.state import capture_notice, is_agent_mode self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user" resp = self._client.request(method, path, **kwargs) @@ -48,7 +48,26 @@ class PlatformBackend(Backend): resp.raise_for_status() if resp.status_code == 204: return {} - return resp.json() + data = resp.json() + + # Pull the unclaimed-Agent-Mode notice out of the body (or the header + # fallback for endpoints that return non-dict / non-dict-leading + # payloads) and stash it for end-of-command surfacing. + notice = None + if isinstance(data, dict) and "mem0_notice" in data: + notice = data.pop("mem0_notice") + elif ( + isinstance(data, list) + and data + and isinstance(data[0], dict) + and "mem0_notice" in data[0] + ): + notice = data[0].pop("mem0_notice") + if notice is None: + notice = resp.headers.get("X-Mem0-Notice-Message") or None + capture_notice(notice) + + return data def add( self, diff --git a/cli/python/src/mem0_cli/branding.py b/cli/python/src/mem0_cli/branding.py index 7879ec8bf..1e36dfd58 100644 --- a/cli/python/src/mem0_cli/branding.py +++ b/cli/python/src/mem0_cli/branding.py @@ -87,10 +87,12 @@ def print_error(console: Console, message: str, hint: str | None = None) -> None } print(_json.dumps(envelope)) return + from rich.markup import escape + sym = _sym("βœ—", "[error]") - console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}") + console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}") if hint: - console.print(f" [{DIM_COLOR}]{hint}[/]") + console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]") def print_warning(console: Console, message: str) -> None: 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..275cef488 --- /dev/null +++ b/cli/python/src/mem0_cli/commands/agent_mode_cmd.py @@ -0,0 +1,239 @@ +"""Agent Mode commands β€” bootstrap (unattended signup) and claim (OTP-based human upgrade).""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from typing import Any + +import httpx +import typer +from rich.console import Console +from rich.prompt import Prompt + +from mem0_cli.branding import ( + BRAND_COLOR, + DIM_COLOR, + print_error, + print_success, +) +from mem0_cli.config import Mem0Config, save_config + +console = Console() +err_console = Console(stderr=True) + +_SOURCE_HEADERS = { + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "python", +} + + +def _validate_envelope(envelope: Any) -> None: + """Defend against partial/malformed backend responses. + + A backend regression that returns ``{"api_key": null}`` would otherwise be + silently persisted, producing confusing downstream errors far from the + source. Fail fast with a clear message if the required fields are missing. + """ + if not isinstance(envelope, dict): + print_error(err_console, "Bootstrap response was not a JSON object.") + raise typer.Exit(1) + for field in ("api_key", "default_user_id"): + value = envelope.get(field) + if not isinstance(value, str) or not value: + print_error( + err_console, + f"Bootstrap response missing required field {field!r} β€” please update the CLI.", + ) + raise typer.Exit(1) + + +def bootstrap_via_backend( + config: Mem0Config, + *, + source: str | None = None, + agent_caller: str | None = None, +) -> None: + """POST /api/v1/auth/agent_mode/ and mutate config in place. + + Args: + config: Mem0Config mutated in place with the new platform values. + source: ``--source`` flag passthrough (analytics tag, free-form). + agent_caller: Self-declared agent identity passed via ``--agent-caller`` + (e.g. ``claude-code``, ``cursor``). May be None when the caller + omitted the flag; the agent can backfill later via + ``mem0 identify ``. Sent to the backend in the request body + and saved into ``platform.agent_caller`` for local introspection. + + 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 + if agent_caller: + body["agent_caller"] = agent_caller + + 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: + detail = resp.text + try: + err_body = resp.json() + detail = err_body.get("error") or err_body.get("detail") or resp.text + except (json.JSONDecodeError, ValueError, AttributeError): + pass + # Backend's @ratelimit decorator raises PermissionDenied, which DRF + # translates to a generic 403 "You do not have permission to perform + # this action." That's opaque β€” surface as the rate-limit it actually is. + if resp.status_code == 403 and "permission" in str(detail).lower(): + print_error( + err_console, + "Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.", + ) + raise typer.Exit(1) + print_error(err_console, f"Bootstrap failed: {detail}") + raise typer.Exit(1) + + envelope = resp.json() + _validate_envelope(envelope) + 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.agent_caller = agent_caller or "" + 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']}") + notice = envelope.get("mem0_notice") + if notice: + console.print(f"\n[yellow]πŸ”” {notice}[/yellow]\n") + else: + # Fallback if the backend hasn't deployed the unified notice yet. + claim_cmd = envelope.get("claim_command", "mem0 init --email ") + console.print(f" [{DIM_COLOR}]To claim this account later: {claim_cmd}[/]") + + if not agent_caller: + console.print( + f" [{DIM_COLOR}]If you are an AI agent, identify yourself: " + f"`mem0 identify ` (e.g. claude-code, cursor).[/]" + ) + + +def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None: + """Claim an existing Agent Mode account via OTP β€” no browser, no polling. + + Reuses the standard email-code flow (`/api/v1/auth/email_code/` then + `/.../verify/`) and adds the local agent-mode API key in the verify body + as `agent_mode_api_key`. Backend's `verify_email_code` runs the + upgrade-in-place transaction inline and returns claim result. + + On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps + `user_email`. The api_key value itself never changes. + """ + 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) + + raw_key = config.platform.api_key + + with httpx.Client(timeout=30.0) as client: + # Step 1: request OTP (unless --code provided) + if not code: + send = client.post( + f"{base_url}/api/v1/auth/email_code/", + headers={**_SOURCE_HEADERS, "Content-Type": "application/json"}, + json={"email": email}, + ) + if send.status_code == 429: + print_error(err_console, "Too many attempts. Try again in a few minutes.") + raise typer.Exit(1) + if send.status_code != 200: + try: + detail = send.json().get("error", send.text) + except Exception: + detail = send.text + print_error(err_console, f"Failed to send code: {detail}") + raise typer.Exit(1) + + print_success(console, f"Verification code sent to {email}. Check your inbox.") + + if not sys.stdin.isatty(): + print_error( + err_console, + "No --code provided and terminal is non-interactive.", + hint=f"Re-run: mem0 init --email {email} --code ", + ) + raise typer.Exit(1) + + console.print() + code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]") + if not code: + print_error(err_console, "Code is required.") + raise typer.Exit(1) + + # Step 2: verify + claim in one shot + verify = client.post( + f"{base_url}/api/v1/auth/email_code/verify/", + headers={**_SOURCE_HEADERS, "Content-Type": "application/json"}, + json={ + "email": email, + "code": code.strip(), + "agent_mode_api_key": raw_key, + }, + ) + + if verify.status_code != 200: + try: + err_body = verify.json() + detail = err_body.get("error", verify.text) + code_str = err_body.get("code", "") + except (json.JSONDecodeError, ValueError, AttributeError): + detail = verify.text + code_str = "" + print_error(err_console, f"Claim failed: {detail}") + if code_str == "email_already_claimed": + console.print( + f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in there and run `mem0 link ` to attach this agent.[/]" + ) + raise typer.Exit(1) + + claim_body = verify.json() + if not claim_body.get("claimed"): + print_error(err_console, f"Unexpected verify response: {claim_body}") + raise typer.Exit(1) + + config.platform.agent_mode = False + config.platform.claimed_at = claim_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.") + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/cli/python/src/mem0_cli/commands/identify_cmd.py b/cli/python/src/mem0_cli/commands/identify_cmd.py new file mode 100644 index 000000000..bc2d41137 --- /dev/null +++ b/cli/python/src/mem0_cli/commands/identify_cmd.py @@ -0,0 +1,75 @@ +"""mem0 identify β€” declare which agent owns the current agent-mode key. + +Used when `mem0 init --agent` ran without --agent-caller, so the backend +saved agent_caller=NULL. The agent re-runs `mem0 identify ` to PATCH +its own row with its real identity. Idempotent β€” running it again just +overwrites. +""" + +from __future__ import annotations + +import httpx +import typer +from rich.console import Console + +from mem0_cli.branding import print_error, print_success +from mem0_cli.config import load_config, save_config + +console = Console() +err_console = Console(stderr=True) + +_SOURCE_HEADERS = { + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "python", +} + + +def run_identify(name: str) -> None: + """PATCH the active agent-mode key's agent_caller field.""" + config = load_config() + if not config.platform.api_key: + print_error( + err_console, + "No API key configured. Run `mem0 init --agent` first.", + ) + raise typer.Exit(1) + if not config.platform.agent_mode: + print_error( + err_console, + "This command only works on unclaimed agent-mode keys.", + ) + raise typer.Exit(1) + + name = (name or "").strip() + if not name: + print_error(err_console, "Agent name is required.") + raise typer.Exit(1) + + base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/") + try: + with httpx.Client(timeout=30.0) as client: + resp = client.patch( + f"{base_url}/api/v1/auth/agent_mode/caller/", + headers={ + **_SOURCE_HEADERS, + "Authorization": f"Token {config.platform.api_key}", + "Content-Type": "application/json", + }, + json={"agent_caller": name}, + ) + except httpx.HTTPError as exc: + print_error(err_console, f"Network error: {exc}") + raise typer.Exit(1) from exc + + if resp.status_code != 200: + try: + detail = resp.json().get("error", resp.text) + except Exception: + detail = resp.text + print_error(err_console, f"Identify failed: {detail}") + raise typer.Exit(1) + + canonical = resp.json().get("agent_caller", name) + config.platform.agent_caller = canonical + save_config(config) + print_success(console, f"Identified as {canonical}.") diff --git a/cli/python/src/mem0_cli/commands/init_cmd.py b/cli/python/src/mem0_cli/commands/init_cmd.py index 22de4d7be..f491ec9d4 100644 --- a/cli/python/src/mem0_cli/commands/init_cmd.py +++ b/cli/python/src/mem0_cli/commands/init_cmd.py @@ -103,6 +103,25 @@ def _validate_email(email: str) -> None: raise typer.Exit(1) +def _ping_key(api_key: str, base_url: str, timeout: float = 5.0) -> bool: + """Validate api_key against /v1/ping/. + + Returns False ONLY on a definitive "invalid key" signal (HTTP 401 / 403). + Network errors, timeouts, and 5xx responses return True so we prefer + reusing an existing key over silently minting a new shadow on a transient + blip (which would also clobber config + plugin-sync targets). + """ + try: + resp = httpx.get( + f"{base_url.rstrip('/')}/v1/ping/", + headers={"Authorization": f"Token {api_key}"}, + timeout=timeout, + ) + except httpx.HTTPError: + return True # unknown β€” prefer reuse + return resp.status_code not in (401, 403) + + def _email_login( email: str, code: str | None, @@ -182,21 +201,143 @@ def run_init( email: str | None = None, code: str | None = None, force: bool = False, + source: str | None = None, + agent: bool = False, + agent_caller: str | None = None, ) -> 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_otp + 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} + if agent_caller: + # Self-declared via --agent-caller; not sniffed from env vars. + 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_otp(existing, email=email, code=code) + _fire_init("email", claimed=True) + return + + # ── Agent Mode path runs BEFORE the existing-config guard ────────── + # Rules 1/2 REUSE a valid existing key (not overwrite), so we must + # short-circuit before the guard prompts. Rule 3 mints only when there + # is no valid key to reuse β€” in that case overwriting is correct. + _agent_ctx = agent or _global_agent_mode() or (detect_agent_caller() is not None) + if not api_key and not email and _agent_ctx: + from mem0_cli.output import format_json_envelope + from mem0_cli.state import is_agent_mode as _is_json_mode + + def _emit_reuse(source: str) -> None: + if _is_json_mode(): + format_json_envelope( + console, + command="init", + data={ + "api_key_saved": False, + "api_key_source": source, + "agent_mode": False, + "message": "Existing Mem0 API key found and reused. No Agent Mode key was created.", + }, + ) + else: + msg = ( + "Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted." + if source == "env" + else "Existing API key in config is valid; reusing it. No new Agent Mode key was minted." + ) + print_success(console, msg) + + def _maybe_identify(key: str) -> None: + """Best-effort PATCH agent_caller when --agent-caller is supplied on a + reused key. Silent no-op on any failure β€” reuse must not break. + """ + if not agent_caller: + return + try: + resp = httpx.patch( + f"{base_url.rstrip('/')}/api/v1/auth/agent_mode/caller/", + headers={ + "Authorization": f"Token {key}", + "Content-Type": "application/json", + }, + json={"agent_caller": agent_caller}, + timeout=10.0, + ) + # Also reflect in local config so introspection matches backend. + if resp.status_code == 200 and CONFIG_FILE.exists(): + try: + cfg = load_config() + cfg.platform.agent_caller = resp.json().get("agent_caller", agent_caller) + save_config(cfg) + except Exception: + pass + except httpx.HTTPError: + pass + + # Rule 1: env MEM0_API_KEY valid β†’ reuse, no new key. + _env_key = (os.environ.get("MEM0_API_KEY") or "").strip() + if _env_key and _ping_key(_env_key, base_url): + _maybe_identify(_env_key) + _emit_reuse("env") + _fire_init("existing_key") + return + # Rule 2: existing config api_key valid β†’ reuse. + if CONFIG_FILE.exists(): + _existing = load_config() + if _existing.platform.api_key and _ping_key(_existing.platform.api_key, base_url): + _maybe_identify(_existing.platform.api_key) + _emit_reuse("config") + _fire_init("existing_key") + return + # Rule 3: mint a fresh shadow (no valid key to reuse). + # agent_caller is the agent's self-declared identity from --agent-caller + # (Proof Editor-style). Env-var auto-detect is still used above to + # decide we're in an agent context, but never to fill identity. + bootstrap_via_backend(config, source=source, agent_caller=agent_caller) + _fire_init("agent") + return + # Warn if an existing config with an API key would be overwritten if not force and CONFIG_FILE.exists(): existing = load_config() @@ -242,6 +383,7 @@ def run_init( config.platform.api_key = api_key_val config.platform.base_url = base_url config.platform.user_email = email + config.platform.created_via = "email" config.defaults.user_id = ( user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" ) @@ -258,6 +400,8 @@ def run_init( return # ── API key flow (existing) ─────────────────────────────────────── + # (Agent Mode branch runs earlier β€” see above, before the existing-config + # guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.) # Non-TTY: resolve defaults so partial flags work in pipelines / CI if not sys.stdin.isatty(): @@ -265,7 +409,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" @@ -273,6 +417,7 @@ def run_init( # Fully non-interactive when both flags provided if api_key and user_id: config.platform.api_key = api_key + config.platform.created_via = "api_key" config.defaults.user_id = user_id _validate_platform(config) save_config(config) @@ -313,6 +458,7 @@ def run_init( config.platform.api_key = api_key_val config.platform.base_url = base_url config.platform.user_email = email_addr + config.platform.created_via = "email" config.defaults.user_id = ( user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" ) @@ -331,6 +477,7 @@ def run_init( # API key flow if api_key: config.platform.api_key = api_key + config.platform.created_via = "api_key" else: _setup_platform(config) @@ -370,6 +517,7 @@ def _setup_platform(config: Mem0Config) -> None: raise typer.Exit(1) config.platform.api_key = api_key + config.platform.created_via = "api_key" def _setup_defaults(config: Mem0Config) -> None: diff --git a/cli/python/src/mem0_cli/config.py b/cli/python/src/mem0_cli/config.py index ebf81fc80..6cd11c6ce 100644 --- a/cli/python/src/mem0_cli/config.py +++ b/cli/python/src/mem0_cli/config.py @@ -28,6 +28,14 @@ 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" + agent_caller: str = ( + "" # canonical agent name when created_via == "agent_mode" (e.g. "claude-code") + ) + 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 +91,11 @@ 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.agent_caller = plat.get("agent_caller", "") + 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 +149,11 @@ 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, + "agent_caller": config.platform.agent_caller, + "claimed_at": config.platform.claimed_at, + "default_user_id": config.platform.default_user_id, }, "telemetry": { "anonymous_id": config.telemetry.anonymous_id, @@ -147,6 +165,19 @@ def save_config(config: Mem0Config) -> None: os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600 + # Propagate the active api_key to ecosystem touchpoints (Claude Code + # plugin env injection, shell rc exports). Idempotent β€” only updates + # EXISTING entries; never creates new ones. Best-effort: any IOError + # in the sync is swallowed so config.json is always the authoritative + # write, never blocked by plugin-state issues. + if config.platform.api_key: + try: + from mem0_cli.plugin_sync import sync_api_key + + sync_api_key(config.platform.api_key) + except Exception: + pass + def redact_key(key: str) -> str: """Redact an API key for display: m0-xxx...xxx""" diff --git a/cli/python/src/mem0_cli/output.py b/cli/python/src/mem0_cli/output.py index dd023e194..f9b8d2d35 100644 --- a/cli/python/src/mem0_cli/output.py +++ b/cli/python/src/mem0_cli/output.py @@ -229,6 +229,16 @@ def format_json_envelope( if error: envelope["error"] = error envelope["data"] = data + + # If the platform flagged this as an unclaimed Agent Mode account, surface + # the notice inside the JSON envelope so an agent consuming the output + # sees it without needing to inspect HTTP headers. + from mem0_cli.state import take_notice + + notice = take_notice() + if notice: + envelope["mem0_notice"] = notice + console.print_json(json.dumps(envelope, default=str)) @@ -323,6 +333,15 @@ def format_agent_envelope( if count is not None: envelope["count"] = count envelope["data"] = sanitize_agent_data(command, data) + + # Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an + # agent reading the JSON output sees it without inspecting HTTP headers. + from mem0_cli.state import take_notice + + notice = take_notice() + if notice: + envelope["mem0_notice"] = notice + console.print_json(json.dumps(envelope, default=str)) diff --git a/cli/python/src/mem0_cli/plugin_sync.py b/cli/python/src/mem0_cli/plugin_sync.py new file mode 100644 index 000000000..ab9ba76ef --- /dev/null +++ b/cli/python/src/mem0_cli/plugin_sync.py @@ -0,0 +1,119 @@ +"""Sync the active Mem0 API key into other ecosystem touchpoints. + +Why this exists: + The CLI canonical state lives in ``~/.mem0/config.json``. But MCP servers + (Claude Code plugin, Codex plugin, etc.) read ``MEM0_API_KEY`` from env + vars or their own config files. Without a sync, an agent-mode bootstrap + mints a new key into config.json but the plugin's MCP keeps using the + old key from env β€” silent surprise. + +Design: + - Update ONLY entries that already exist (never create new ones) + - Preserve all surrounding content / formatting / other keys + - Atomic writes (tmpfile + rename) so a crash mid-write doesn't corrupt + - Idempotent β€” re-running with the same key is a no-op + - Skip on dry_run + +Targets currently handled: + - ``~/.claude/settings.json::env::MEM0_API_KEY`` (Claude Code env injection) + - ``~/.zshrc`` / ``~/.bashrc`` ``export MEM0_API_KEY="..."`` lines + +Out of scope (deliberately not touched): + - Codex / Cursor MCP configs β€” would require schema-aware edits and + those tools don't have mem0 entries by default + - Plugin's own ``/.api_key`` file β€” plugin-managed +""" + +from __future__ import annotations + +import contextlib +import json +import os +import re +import tempfile +from pathlib import Path + +# Files we know how to update safely. +_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json" +_SHELL_RCS = [Path.home() / ".zshrc", Path.home() / ".bashrc", Path.home() / ".bash_profile"] + + +def sync_api_key(api_key: str) -> list[str]: + """Propagate ``api_key`` into known ecosystem touchpoints. + + Returns the list of paths actually updated. Empty list means nothing + needed updating (either targets didn't exist or already had this value). + """ + if not api_key: + return [] + updated: list[str] = [] + if _update_claude_settings(_CLAUDE_SETTINGS, api_key): + updated.append(str(_CLAUDE_SETTINGS)) + for rc in _SHELL_RCS: + if _update_shell_rc(rc, api_key): + updated.append(str(rc)) + return updated + + +def _update_claude_settings(path: Path, api_key: str) -> bool: + """Update ``env.MEM0_API_KEY`` in path. Returns True if file was changed.""" + if not path.is_file(): + return False + try: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return False + env = data.get("env") + if not isinstance(env, dict) or "MEM0_API_KEY" not in env: + # No existing entry β€” don't create one. + return False + if env["MEM0_API_KEY"] == api_key: + return False # already in sync + env["MEM0_API_KEY"] = api_key + _atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n") + return True + + +# Match `export MEM0_API_KEY="..."` (or single quotes, or no quotes). +# Use [ \t]* (not \s*) for trailing whitespace so a trailing newline at +# end-of-file is preserved when MEM0_API_KEY is the last line. +_RC_LINE = re.compile( + r'^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["\']?)([^"\'\n]*)(["\']?)[ \t]*$', + re.MULTILINE, +) + + +def _update_shell_rc(path: Path, api_key: str) -> bool: + """Update an existing ``export MEM0_API_KEY=...`` line in path.""" + if not path.is_file(): + return False + try: + text = path.read_text(encoding="utf-8") + except OSError: + return False + match = _RC_LINE.search(text) + if not match: + return False # no existing line + if match.group(3) == api_key: + return False + new_text = _RC_LINE.sub(lambda m: f'{m.group(1)}"{api_key}"', text, count=1) + _atomic_write_text(path, new_text) + return True + + +def _atomic_write_text(path: Path, content: str) -> None: + """Write content to path atomically (temp + rename).""" + dirname = path.parent + fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=dirname) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + # Preserve mode if the original existed. + if path.exists(): + os.chmod(tmp_path, path.stat().st_mode & 0o777) + os.replace(tmp_path, path) + except Exception: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + raise diff --git a/cli/python/src/mem0_cli/state.py b/cli/python/src/mem0_cli/state.py index c84f34c3e..81b66acfd 100644 --- a/cli/python/src/mem0_cli/state.py +++ b/cli/python/src/mem0_cli/state.py @@ -4,6 +4,7 @@ from __future__ import annotations _agent_mode: bool = False _current_command: str = "" +_pending_notice: str = "" def is_agent_mode() -> bool: @@ -22,3 +23,23 @@ def get_current_command() -> str: def set_current_command(name: str) -> None: global _current_command _current_command = name + + +def capture_notice(notice: str | None) -> None: + """Stash a Mem0 backend notice for end-of-command surfacing. + + Called from the platform backend after each response so the notice can + be printed once per command (regardless of how many sub-requests fired). + Last-write-wins is fine β€” the message text is identical across requests. + """ + global _pending_notice + if notice: + _pending_notice = notice + + +def take_notice() -> str: + """Return and clear the pending notice.""" + global _pending_notice + msg = _pending_notice + _pending_notice = "" + return msg 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(), diff --git a/cli/python/tests/test_agent_mode.py b/cli/python/tests/test_agent_mode.py new file mode 100644 index 000000000..78f0ca48c --- /dev/null +++ b/cli/python/tests/test_agent_mode.py @@ -0,0 +1,157 @@ +"""Parity tests for `mem0 init --agent` (Agent Mode bootstrap). + +Mirror of ``cli/node/tests/agent-mode.test.ts`` β€” both files MUST stay in +sync so that the Python and Node CLIs expose an identical surface for the +Agent Mode entrypoint. If you add a flag here, add the same assertion on +the Node side (and vice versa). + +Network-bound bootstrap is covered by the platform-side E2E suite +(``backend/tests/e2e/test_05_agent_mode.py``); these tests only verify +the CLI surface that ships in the binary. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys + +import pytest + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +def _run(args: list[str], home_dir: str | None = None) -> subprocess.CompletedProcess: + env = os.environ.copy() + for key in list(env.keys()): + if key.startswith("MEM0_"): + del env[key] + env.pop("FORCE_COLOR", None) + if home_dir: + env["HOME"] = home_dir + result = subprocess.run( + [sys.executable, "-m", "mem0_cli", *args], + capture_output=True, + text=True, + env=env, + timeout=15, + ) + return subprocess.CompletedProcess( + args=result.args, + returncode=result.returncode, + stdout=_strip_ansi(result.stdout), + stderr=_strip_ansi(result.stderr), + ) + + +@pytest.fixture +def clean_home(tmp_path): + return str(tmp_path) + + +class TestInitFlagSurface: + """`mem0 init --help` must expose the Agent Mode flags.""" + + def test_init_help_lists_agent_flag(self): + result = _run(["init", "--help"]) + assert result.returncode == 0 + assert "--agent" in result.stdout + + def test_init_help_describes_agent_mode(self): + result = _run(["init", "--help"]) + assert result.returncode == 0 + # Description must mention what --agent actually does so an agent + # reading the help can self-discover the bootstrap entrypoint. + assert "Agent Mode" in result.stdout or "unattended" in result.stdout.lower() + + def test_init_help_lists_source_flag(self): + result = _run(["init", "--help"]) + assert result.returncode == 0 + assert "--source" in result.stdout + + def test_init_help_lists_email_and_code(self): + # Claim flow flags must remain present alongside Agent Mode flags. + result = _run(["init", "--help"]) + assert result.returncode == 0 + assert "--email" in result.stdout + assert "--code" in result.stdout + + +class TestArgvPreprocessing: + """`--agent` on `init` must reach init_cmd, not be eaten by the global preprocessor. + + Regression for the bug where the top-level `--agent` JSON-alias was + stripped from ``sys.argv`` before Typer could bind it to the init + subcommand, making ``mem0 init --agent`` indistinguishable from a + plain ``mem0 init`` (interactive wizard). + """ + + def test_init_with_agent_reaches_subcommand(self, clean_home): + # We can't hit a real backend in unit tests, so we point the CLI at + # a guaranteed-dead URL and assert the failure is the bootstrap + # request failing β€” proving the --agent flag was honored and the + # bootstrap branch ran, not the interactive wizard. + result = subprocess.run( + [sys.executable, "-m", "mem0_cli", "init", "--agent"], + capture_output=True, + text=True, + env={ + **{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")}, + "HOME": clean_home, + "MEM0_BASE_URL": "http://127.0.0.1:1", # blackhole + "FORCE_COLOR": "0", + }, + timeout=15, + ) + combined = _strip_ansi(result.stdout + result.stderr).lower() + # Either we got a connection/network error from the bootstrap POST, + # or the CLI surfaced an Agent Mode-specific failure message. + assert ( + "agent" in combined + or "connect" in combined + or "network" in combined + or "fetch" in combined + or "bootstrap" in combined + ), f"Expected bootstrap attempt, got: {combined!r}" + + +class TestJsonEnvelopeParity: + """`mem0 init --agent --json` should produce a JSON envelope on success. + + Without a live backend we can only assert the failure shape: when the + backend is unreachable, the CLI must still exit non-zero AND not crash + on a Python traceback (which would mean we leaked an exception past + the agent-mode handler). + """ + + def test_init_agent_json_no_traceback_on_network_failure(self, clean_home): + result = subprocess.run( + [sys.executable, "-m", "mem0_cli", "init", "--agent", "--json"], + capture_output=True, + text=True, + env={ + **{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")}, + "HOME": clean_home, + "MEM0_BASE_URL": "http://127.0.0.1:1", + "FORCE_COLOR": "0", + }, + timeout=15, + ) + combined = _strip_ansi(result.stdout + result.stderr) + assert "Traceback (most recent call last)" not in combined + assert result.returncode != 0 + + +class TestInitInCommandList: + """`mem0 --help` must list `init` so agents walking the top-level help + can discover the Agent Mode entrypoint without prior knowledge.""" + + def test_top_level_help_lists_init(self): + result = _run(["--help"]) + assert result.returncode == 0 + assert "init" in result.stdout diff --git a/cli/python/tests/test_init_internals.py b/cli/python/tests/test_init_internals.py new file mode 100644 index 000000000..141f6018e --- /dev/null +++ b/cli/python/tests/test_init_internals.py @@ -0,0 +1,206 @@ +"""Unit tests for init internals β€” decision tree primitives + plugin sync. + +These tests exercise the units that the high-level subprocess parity tests in +``test_agent_mode.py`` deliberately can't reach: + + - ``_ping_key`` must NOT treat network errors as "invalid key" (else a VPN + flap silently mints a new shadow over a working key). + - ``plugin_sync`` must only update entries that already exist, preserve + trailing newlines, and never mangle other lines. + - The 403β†’ratelimit translation in ``bootstrap_via_backend`` surfaces the + real cause instead of DRF's opaque "You do not have permission" string. + +Mirror surface lives in ``cli/node/tests/agent-mode.test.ts``; if you add a +behavioral assertion here, mirror it on the Node side and vice versa. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import httpx +import pytest + +from mem0_cli.commands.init_cmd import _ping_key +from mem0_cli.plugin_sync import _update_claude_settings, _update_shell_rc + +# ── _ping_key ────────────────────────────────────────────────────────────── + + +class _Resp: + def __init__(self, status_code: int) -> None: + self.status_code = status_code + + +def test_ping_key_200_is_valid(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(200)) + assert _ping_key("k", "http://x") is True + + +def test_ping_key_401_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(401)) + assert _ping_key("k", "http://x") is False + + +def test_ping_key_403_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(403)) + assert _ping_key("k", "http://x") is False + + +def test_ping_key_5xx_is_not_definitively_invalid(monkeypatch: pytest.MonkeyPatch) -> None: + # Transient upstream failure must NOT cause a shadow to be minted. + monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(503)) + assert _ping_key("k", "http://x") is True + + +def test_ping_key_connect_error_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None: + # Network blip (DNS, captive portal, etc.) β€” must NOT trigger a re-mint. + def boom(*a, **kw): + raise httpx.ConnectError("nope") + + monkeypatch.setattr(httpx, "get", boom) + assert _ping_key("k", "http://x") is True + + +def test_ping_key_timeout_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*a, **kw): + raise httpx.ReadTimeout("slow") + + monkeypatch.setattr(httpx, "get", boom) + assert _ping_key("k", "http://x") is True + + +# ── plugin_sync._update_shell_rc ────────────────────────────────────────── + + +def test_shell_rc_updates_existing_export_preserves_trailing_newline(tmp_path) -> None: + rc = tmp_path / ".zshrc" + rc.write_text('export MEM0_API_KEY="old"\n', encoding="utf-8") + changed = _update_shell_rc(rc, "newkey") + assert changed is True + assert rc.read_text(encoding="utf-8") == 'export MEM0_API_KEY="newkey"\n' + + +def test_shell_rc_does_not_create_new_export(tmp_path) -> None: + rc = tmp_path / ".zshrc" + rc.write_text("alias ll='ls -la'\n", encoding="utf-8") + changed = _update_shell_rc(rc, "newkey") + assert changed is False + assert rc.read_text(encoding="utf-8") == "alias ll='ls -la'\n" + + +def test_shell_rc_preserves_surrounding_content(tmp_path) -> None: + rc = tmp_path / ".zshrc" + original = "# my zshrc\nalias ll='ls -la'\nexport MEM0_API_KEY='old'\nexport OTHER=keepme\n" + rc.write_text(original, encoding="utf-8") + _update_shell_rc(rc, "newkey") + after = rc.read_text(encoding="utf-8") + assert "alias ll='ls -la'\n" in after + assert "export OTHER=keepme\n" in after + assert "# my zshrc\n" in after + assert 'export MEM0_API_KEY="newkey"\n' in after + + +def test_shell_rc_idempotent_when_already_matching(tmp_path) -> None: + rc = tmp_path / ".zshrc" + rc.write_text('export MEM0_API_KEY="same"\n', encoding="utf-8") + assert _update_shell_rc(rc, "same") is False + + +def test_shell_rc_missing_file_is_noop(tmp_path) -> None: + rc = tmp_path / ".zshrc" # does not exist + assert _update_shell_rc(rc, "x") is False + + +# ── plugin_sync._update_claude_settings ──────────────────────────────────── + + +def test_claude_settings_does_not_create_env_block(tmp_path) -> None: + import json + + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"otherKey": 1}), encoding="utf-8") + changed = _update_claude_settings(settings, "newkey") + assert changed is False + # Original content unchanged. + assert json.loads(settings.read_text(encoding="utf-8")) == {"otherKey": 1} + + +def test_claude_settings_does_not_create_mem0_entry_in_existing_env(tmp_path) -> None: + import json + + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"env": {"OTHER_KEY": "x"}}), encoding="utf-8") + changed = _update_claude_settings(settings, "newkey") + assert changed is False + + +def test_claude_settings_updates_existing_entry(tmp_path) -> None: + import json + + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps({"env": {"MEM0_API_KEY": "old", "OTHER": "y"}}, indent=2), + encoding="utf-8", + ) + changed = _update_claude_settings(settings, "fresh") + assert changed is True + data = json.loads(settings.read_text(encoding="utf-8")) + assert data["env"]["MEM0_API_KEY"] == "fresh" + assert data["env"]["OTHER"] == "y" # other keys preserved + + +def test_claude_settings_idempotent(tmp_path) -> None: + import json + + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"env": {"MEM0_API_KEY": "same"}}), encoding="utf-8") + assert _update_claude_settings(settings, "same") is False + + +def test_claude_settings_malformed_json_is_noop(tmp_path) -> None: + settings = tmp_path / "settings.json" + settings.write_text("{ this is not json", encoding="utf-8") + assert _update_claude_settings(settings, "x") is False + + +# ── bootstrap rate-limit translation ────────────────────────────────────── + + +def test_bootstrap_403_permission_surfaces_ratelimit(monkeypatch, capsys) -> None: + """DRF 403 'You do not have permission' must be translated to the daily limit message.""" + from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend + from mem0_cli.config import Mem0Config + + fake_resp = MagicMock() + fake_resp.status_code = 403 + fake_resp.text = '{"detail": "You do not have permission to perform this action."}' + fake_resp.json = MagicMock( + return_value={"detail": "You do not have permission to perform this action."} + ) + + class _Client: + def __init__(self, *a, **kw): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def post(self, *a, **kw): + return fake_resp + + monkeypatch.setattr(httpx, "Client", _Client) + cfg = Mem0Config() + cfg.platform.base_url = "https://api.mem0.ai" + import typer + + with pytest.raises(typer.Exit): + bootstrap_via_backend(cfg) + + captured = capsys.readouterr() + combined = captured.out + captured.err + assert "Daily Agent Mode signup limit reached" in combined + assert "permission to perform this action" not in combined diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index c9db329fe..d43c574a5 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -1323,6 +1323,23 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to + + +**New Features:** +- **Agent Mode (`mem0 init --agent`):** Zero-friction signup for AI agents β€” mints a working Mem0 API key in <5s with no email, no dashboard, no OTP. Returns an unclaimed shadow account the human can later claim with `mem0 init --email ` (memories preserved, same key keeps working) ([#5123](https://github.com/mem0ai/mem0/pull/5123)) +- **Self-declared agent identity:** Agents pass `--agent-caller ` (e.g. `claude-code`, `cursor`, `codex`) on `mem0 init --agent` so signups attribute to the right tool in analytics. Proof Editor-style β€” the agent declares itself rather than the CLI sniffing it from env vars ([#5123](https://github.com/mem0ai/mem0/pull/5123)) +- **`mem0 identify `:** New subcommand to self-tag an Agent Mode key after the fact when the agent forgot to pass `--agent-caller` on init. Idempotent β€” re-running just overwrites ([#5123](https://github.com/mem0ai/mem0/pull/5123)) +- **Plugin sync:** `~/.claude/settings.json::env::MEM0_API_KEY` and `~/.zshrc`/`.bashrc` `export MEM0_API_KEY=` lines stay in sync with `~/.mem0/config.json` automatically. Idempotent β€” only updates EXISTING entries, never creates new ones ([#5123](https://github.com/mem0ai/mem0/pull/5123)) +- **Claim flow:** `mem0 init --email ` claims an existing Agent Mode shadow via OTP. Upgrade-in-place β€” the API key never changes, memories transfer to the human's account ([#5123](https://github.com/mem0ai/mem0/pull/5123)) + +**Bug Fixes:** +- **Decision tree network resilience:** `pingKey` now distinguishes network errors from invalid keys β€” returns false ONLY on HTTP 401/403, returns true on connection failures / timeouts / 5xx. Prevents a VPN flap from silently rotating the user's API key and rewriting plugin-sync targets ([#5123](https://github.com/mem0ai/mem0/pull/5123)) +- **Rate-limit error clarity:** DRF's opaque `"You do not have permission"` 403 from Agent Mode rate limits is now translated to `"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC."` ([#5123](https://github.com/mem0ai/mem0/pull/5123)) +- **JSON envelope `command` field:** `mem0 init --agent --json` error envelopes now populate the `command` field correctly instead of returning an empty string ([#5123](https://github.com/mem0ai/mem0/pull/5123)) +- **Bootstrap envelope validation:** Defends against partial/malformed backend responses (e.g. `{api_key: null}`) silently persisting null/undefined into typed string fields ([#5123](https://github.com/mem0ai/mem0/pull/5123)) + + + **New Features:** diff --git a/docs/llms.txt b/docs/llms.txt index 7ef58b201..937856545 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -11,6 +11,12 @@ - Live MCP server: https://mcp.mem0.ai (see `platform/mem0-mcp`). - Source repo: https://github.com/mem0ai/mem0 +### Don't have a Mem0 API key? + +Run: `mem0 init --agent --agent-caller --json` β€” replace `` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify ` after init to attribute the signup. + +This mints an evaluation key in <5 seconds β€” no email, no dashboard, no OTP required. Use it immediately against the Platform API or MCP server. When the human is ready, claim with `mem0 init --email ` to transfer ownership (memories preserved, same API key, no agent disruption). + ## Install - Python SDK: `pip install mem0ai` diff --git a/mem0-plugin/README.md b/mem0-plugin/README.md index 732c44681..9e28aa507 100644 --- a/mem0-plugin/README.md +++ b/mem0-plugin/README.md @@ -2,6 +2,18 @@ Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Claude Code** (CLI), **Claude Cowork** (desktop app), **Cursor**, and **Codex**. +## Quick path for agents + +If you're an AI agent setting up Mem0 autonomously (no human dashboard signup available), run: + +```bash +mem0 init --agent --json +``` + +This mints an evaluation API key in <5 seconds β€” no email, no OTP, no browser. The key is written to your config and exported as `MEM0_API_KEY` for the current shell. Use it immediately against the Mem0 Platform or the MCP server. The human owner can later run `mem0 init --email ` to claim ownership: memories transfer, the same API key keeps working, and the agent isn't disrupted. + +Humans setting up Mem0 by hand should continue with Step 1 below. + ## Step 1: Set your API key > **You must complete this step before installing the plugin.** diff --git a/mem0-plugin/skills/mem0/SKILL.md b/mem0-plugin/skills/mem0/SKILL.md index 1ba8b43f9..e5ee18068 100644 --- a/mem0-plugin/skills/mem0/SKILL.md +++ b/mem0-plugin/skills/mem0/SKILL.md @@ -46,6 +46,8 @@ export MEM0_API_KEY="m0-your-api-key" Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=mem0-plugin-skill +> **Don't have a `MEM0_API_KEY`?** Run `mem0 init --agent --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`) to mint an evaluation key without email or dashboard. The human can claim later with `mem0 init --email `. + ## Step 2: Initialize the client **Python:** diff --git a/mem0/__init__.py b/mem0/__init__.py index 318347ecb..db57fb332 100644 --- a/mem0/__init__.py +++ b/mem0/__init__.py @@ -4,3 +4,5 @@ __version__ = importlib.metadata.version("mem0ai") from mem0.client.main import AsyncMemoryClient, MemoryClient # noqa from mem0.memory.main import AsyncMemory, Memory # noqa + + diff --git a/openclaw/README.md b/openclaw/README.md index 4af44d1b9..4db861feb 100644 --- a/openclaw/README.md +++ b/openclaw/README.md @@ -19,6 +19,18 @@ openclaw --version |------------------|----------------| | `>= 2026.4.25` | Fully supported | +## Quick path for agents + +If you're an AI agent setting up Mem0 autonomously (no human dashboard signup), mint an evaluation API key in <5 seconds: + +```bash +mem0 init --agent --json +``` + +No email, no OTP, no browser. The key is written to your Mem0 CLI config and exported as `MEM0_API_KEY` β€” pass it to `openclaw mem0 init --api-key $MEM0_API_KEY --user-id ` to wire OpenClaw up immediately. The human owner can later run `mem0 init --email ` to claim ownership: memories transfer, same API key keeps working, no agent disruption. + +Humans should follow the Quick Start below. + ## Quick Start ### Platform (Mem0 Cloud) diff --git a/skills/mem0-cli/SKILL.md b/skills/mem0-cli/SKILL.md index 2ac3a18f8..10afd3429 100644 --- a/skills/mem0-cli/SKILL.md +++ b/skills/mem0-cli/SKILL.md @@ -38,7 +38,23 @@ Both packages install a `mem0` binary with identical commands, options, and outp ## Setup -**Interactive wizard:** +**Autonomous Mem0 setup for agents (no email, no dashboard):** + +```bash +mem0 init --agent --agent-caller --json +``` + +Replace `` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`, `cline`, `aider`). If you forgot to pass `--agent-caller`, run `mem0 identify ` after init β€” it PATCHes the same key, idempotent, no disruption. + +This mints an evaluation API key in <5 seconds. Use it immediately. Print this line to the human: + +> Mem0 memory is running in Agent Mode. Please claim it by running `mem0 init --email ` to transfer ownership to your account. + +Never commit API keys, `.env`, or `~/.mem0/config.json`. + +After the human claims, the same API key keeps working β€” no agent disruption β€” and memories are preserved. + +**Interactive wizard (for humans):** ```bash mem0 init ``` diff --git a/skills/mem0-cli/references/command-reference.md b/skills/mem0-cli/references/command-reference.md index 814f6f62c..16f85ed50 100644 --- a/skills/mem0-cli/references/command-reference.md +++ b/skills/mem0-cli/references/command-reference.md @@ -35,12 +35,16 @@ Interactive setup wizard. Configures API key and default user ID. | `--email ` | string | - | Login via email verification code instead of API key. | | `--code ` | string | - | Verification code (use with `--email` for fully non-interactive login). | | `--force` | boolean | false | Overwrite existing config without confirmation. | +| `--agent` | boolean | false | Bootstrap an Agent Mode account (no email required). | +| `--agent-caller ` | string | - | Self-declared agent identity for Agent Mode (e.g. `claude-code`, `cursor`). | +| `--source ` | string | - | Channel attribution for signup analytics. | **Behavior:** - If `~/.mem0/config.json` already exists with an API key, warns and asks for confirmation (or errors in non-TTY unless `--force` is set). - **Email login flow** (`--email`): sends a 6-digit code to the email via `POST /api/v1/auth/email_code/`. If `--code` is also given, verifies immediately. On success, saves API key, org_id, and project_id. Cannot be combined with `--api-key`. - **API key flow**: if both `--api-key` and `--user-id` are given, runs fully non-interactively. Otherwise prompts for missing values. +- **Agent Mode flow** (`--agent`): POSTs to `/api/v1/auth/agent_mode/`, mints a shadow API key in <5s with no email required. Pass `--agent-caller ` to attribute the signup to your AI agent identity. If omitted, run `mem0 identify ` afterward. - In non-TTY without sufficient flags, prints a usage hint and exits with error. **Examples:** @@ -50,6 +54,30 @@ mem0 init --api-key m0-xxx --user-id alice mem0 init --api-key m0-xxx --user-id alice --force mem0 init --email alice@company.com mem0 init --email alice@company.com --code 482901 +mem0 init --agent --agent-caller claude-code # AI agent self-identifies during bootstrap +``` + +--- + +### `mem0 identify` + +Tag your active Agent Mode key with the AI agent that's using it. Run this once after `mem0 init --agent` if you didn't pass `--agent-caller`. Idempotent β€” re-running just overwrites the value. + +**Usage:** `mem0 identify ` + +**Argument:** `` β€” the AI agent identity (e.g. `claude-code`, `cursor`, `codex`, `cline`, `aider`, or a custom string). + +**Behavior:** + +- PATCHes `/api/v1/auth/agent_mode/caller/` with `Authorization: Token ` and body `{agent_caller}`. +- Only works on unclaimed agent-mode keys (`platform.agent_mode=true` in config). +- Backend sanitizes the value: lowercases, drops anything outside `[a-z0-9._/-]`, truncates to 32 chars. + +**Examples:** +```bash +mem0 identify claude-code +mem0 identify cursor +mem0 identify my-custom-bot ``` --- diff --git a/skills/mem0-integrate/SKILL.md b/skills/mem0-integrate/SKILL.md index 8230a63b3..fd686b645 100644 --- a/skills/mem0-integrate/SKILL.md +++ b/skills/mem0-integrate/SKILL.md @@ -248,8 +248,8 @@ Bake the choice into the goal doc in step 5. Do not re-decide later. | OSS (default LLM) | `OPENAI_API_KEY` | https://platform.openai.com/api-keys | If present in env β†’ continue. -If missing β†’ **interactive mode** asks; **CI mode** (`MEM0_INTEGRATE_CI=1`) -exits with code 2 and the name of the missing key. +If `MEM0_API_KEY` is missing AND the track is **Platform** β†’ **default to Agent Mode**: run `mem0 init --agent --agent-caller --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), substituting your agent identity (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify ` after init. Cache the key to `.env` (with user consent) and continue. Tell the user to claim later with `mem0 init --email ` β€” same key, no agent disruption. +If missing AND **CI mode** (`MEM0_INTEGRATE_CI=1`) β†’ exit with code 2 and the name of the missing key. Never echo key values into `trace.jsonl`. Persist to `.env` only with explicit user consent, and append `.env` to `.gitignore` if not already there. diff --git a/skills/mem0/SKILL.md b/skills/mem0/SKILL.md index 8fe7e5c1a..508870f01 100644 --- a/skills/mem0/SKILL.md +++ b/skills/mem0/SKILL.md @@ -46,6 +46,8 @@ export MEM0_API_KEY="m0-your-api-key" Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=skill-mem0 +> **Don't have a `MEM0_API_KEY`?** Run `mem0 init --agent --agent-caller --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), substituting your agent identity (e.g. `claude-code`, `cursor`). If you forgot to pass `--agent-caller`, run `mem0 identify ` after init. The human can claim later with `mem0 init --email `. + ## Step 2: Initialize the client **Python:**