diff --git a/cli/node/src/agent-detect.ts b/cli/node/src/agent-detect.ts new file mode 100644 index 000000000..495f99a5a --- /dev/null +++ b/cli/node/src/agent-detect.ts @@ -0,0 +1,29 @@ +/** + * Detect which AI agent is invoking the CLI via environment variables. + * + * Used by `mem0 init` to: + * 1. Decide whether to auto-bootstrap an Agent Mode key (positive agent signal). + * 2. Tag the `agent_caller` PostHog property on the cli.init event. + * + * Returns a canonical short name or null when no agent is detected. + */ + +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/commands/agent-mode.ts b/cli/node/src/commands/agent-mode.ts new file mode 100644 index 000000000..64c64fd0e --- /dev/null +++ b/cli/node/src/commands/agent-mode.ts @@ -0,0 +1,195 @@ +/** + * Agent Mode commands — bootstrap (unattended signup) and claim (human upgrade). + */ + +import { randomBytes } from "node:crypto"; +import { setTimeout as sleep } from "node:timers/promises"; +import { colors, printError, printInfo, printSuccess } from "../branding.js"; +import { type Mem0Config, saveConfig } from "../config.js"; + +const { dim } = colors; + +const POLL_INTERVAL_MS = 2_000; +const POLL_TIMEOUT_MS = 600_000; // 10 minutes — fits within backend's 15-minute CLILoginRequest expiry. + +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; +} + +export async function bootstrapViaBackend( + config: Mem0Config, + { source }: { source?: string | null } = {}, +): Promise { + const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, ""); + const body: Record = {}; + if (source) body.source = source; + + 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 }; + if (errBody.error) detail = errBody.error; + } catch { + /* leave detail as statusText */ + } + printError(`Bootstrap failed: ${detail}`); + process.exit(1); + } + + const envelope = (await resp.json()) as BootstrapEnvelope; + + config.platform.apiKey = envelope.api_key; + config.platform.baseUrl = baseUrl; + config.platform.agentMode = true; + config.platform.createdVia = "agent_mode"; + 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}`); + console.log( + ` ${dim(`To claim this account later: ${envelope.claim_command ?? "mem0 init --email "}`)}`, + ); +} + +export async function claimViaDeviceFlow( + config: Mem0Config, + { email }: { email: 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 cliToken = randomBytes(32).toString("base64url"); + const rawKey = config.platform.apiKey; + + // 1. CLI initiates with claim_for_apikey + let initResp: Response; + try { + initResp = await fetch(`${baseUrl}/api/v1/accounts/cli_login/`, { + method: "POST", + headers: { + ...SOURCE_HEADERS, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: cliToken, claim_for_apikey: rawKey }), + signal: AbortSignal.timeout(30_000), + }); + } catch (err) { + printError(`Could not initiate claim: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + + if (!initResp.ok) { + let detail: string = initResp.statusText; + try { + const errBody = (await initResp.json()) as { error?: string }; + if (errBody.error) detail = errBody.error; + } catch { + /* statusText fallback */ + } + printError(`Could not initiate claim: ${detail}`); + process.exit(1); + } + + const initBody = (await initResp.json()) as { login_url?: string }; + const loginUrl = initBody.login_url ?? ""; + printInfo("Open in your browser to claim:"); + console.log(` ${dim(loginUrl)}`); + // Best-effort open in the user's browser — fall back to printing the URL. + try { + const { default: open } = await import("open"); + await open(loginUrl); + } catch { + /* user has the URL printed above */ + } + + // 2. Poll for completion + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + + let poll: Response; + try { + poll = await fetch(`${baseUrl}/api/v1/accounts/get_api_key_from_cli_token/`, { + method: "POST", + headers: { + ...SOURCE_HEADERS, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: cliToken }), + signal: AbortSignal.timeout(15_000), + }); + } catch { + continue; // transient — keep polling + } + + if (!poll.ok) { + let err = ""; + try { + const errBody = (await poll.json()) as { error?: string }; + err = errBody.error ?? ""; + } catch { + /* ignore */ + } + if (err.toLowerCase().includes("expired")) { + printError("Claim link expired. Run `mem0 init --email ` again."); + process.exit(1); + } + continue; + } + + const body = (await poll.json()) as { claimed?: boolean; claimed_at?: string }; + if (body.claimed) { + config.platform.agentMode = false; + config.platform.claimedAt = body.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.`); + return; + } + } + + printError("Claim timed out. Run `mem0 init --email ` again."); + process.exit(1); +} diff --git a/cli/node/src/commands/init.ts b/cli/node/src/commands/init.ts index 930b4818f..2c2a26d6d 100644 --- a/cli/node/src/commands/init.ts +++ b/cli/node/src/commands/init.ts @@ -249,14 +249,31 @@ export async function runInit( email?: string; code?: string; force?: boolean; + agent?: boolean; + source?: string; } = {}, ): Promise { + const { detectAgentCaller } = await import("../agent-detect.js"); + const { bootstrapViaBackend, claimViaDeviceFlow } = 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 }; + const caller = detectAgentCaller(); + if (caller) props.agent_caller = caller; + 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 +285,16 @@ 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 claimViaDeviceFlow(savedConfig, { email }); + fireInit("email", true); + return; + } + // Warn if an existing config with an API key would be overwritten if ( !opts.force && @@ -338,6 +365,20 @@ export async function runInit( return; } + // ── Agent Mode auto-bootstrap (no email, no api_key flag) ─────────── + // Positive agent signal required: --agent flag (local or global) OR a + // recognized agent env var. Pure "no TTY" alone is NOT enough — pipe + // users would get surprised by a silent shadow signup. + const agentCtx = + opts.agent === true || + isAgentMode() || + detectAgentCaller() !== null; + if (!opts.apiKey && !opts.email && agentCtx) { + await bootstrapViaBackend(config, { source: opts.source ?? null }); + fireInit("agent"); + return; + } + // ── API key flow ────────────────────────────────────────────────────────── // Non-TTY: resolve defaults so partial flags work in pipelines / CI @@ -345,7 +386,7 @@ export async function runInit( 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); } diff --git a/cli/node/src/config.ts b/cli/node/src/config.ts index 29a0d7157..91cb76d94 100644 --- a/cli/node/src/config.ts +++ b/cli/node/src/config.ts @@ -21,6 +21,11 @@ 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" + claimedAt: string; // ISO timestamp once the agent has been claimed + defaultUserId: string; // `user_` returned by bootstrap; auto-default scope } export interface DefaultsConfig { @@ -54,6 +59,10 @@ export function createDefaultConfig(): Mem0Config { apiKey: "", baseUrl: DEFAULT_BASE_URL, userEmail: "", + agentMode: false, + createdVia: "", + claimedAt: "", + defaultUserId: "", }, telemetry: { anonymousId: "", @@ -79,6 +88,10 @@ 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.claimedAt = plat.claimed_at ?? ""; + config.platform.defaultUserId = plat.default_user_id ?? ""; const defaults = data.defaults ?? {}; config.defaults.userId = defaults.user_id ?? ""; @@ -118,6 +131,10 @@ 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, + claimed_at: config.platform.claimedAt, + default_user_id: config.platform.defaultUserId, }, telemetry: { anonymous_id: config.telemetry.anonymousId, diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index 30db1209b..d329a32af 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -166,6 +166,10 @@ program.hook("preAction", (_thisCommand, actionCommand) => { parentName && parentName !== "mem0" ? `${parentName}.${commandName}` : commandName; + // 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,9 +197,11 @@ 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).") .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) => { const { runInit } = await import("./commands/init.js"); @@ -205,6 +211,8 @@ program email: opts.email, code: opts.code, force: opts.force, + agent: opts.agent, + source: opts.source, }); }); 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,