diff --git a/README.md b/README.md index a5520c6e3..d1233ad1d 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 --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`) to mint an evaluation Mem0 API key in <5 seconds — no email, no dashboard, no OTP. Use it 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/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 index dd351dc9e..33ac2f883 100644 --- a/cli/node/src/commands/agent-mode.ts +++ b/cli/node/src/commands/agent-mode.ts @@ -21,13 +21,17 @@ export interface BootstrapEnvelope { mcp_url?: string; smoke_test_url?: string; claim_command?: string; + mem0_notice?: string; } export async function bootstrapViaBackend( config: Mem0Config, { source }: { source?: string | null } = {}, ): Promise { - const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, ""); + const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace( + /\/+$/, + "", + ); const body: Record = {}; if (source) body.source = source; @@ -43,7 +47,9 @@ export async function bootstrapViaBackend( signal: AbortSignal.timeout(30_000), }); } catch (err) { - printError(`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`); + printError( + `Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`, + ); process.exit(1); } @@ -79,10 +85,16 @@ export async function bootstrapViaBackend( 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 "}`)}`, + 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}`)}`); + } } /** @@ -97,9 +109,14 @@ export async function claimViaOtp( config: Mem0Config, { email, code }: { email: string; code?: string }, ): Promise { - const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, ""); + 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."); + printError( + "This command requires an active Agent Mode config. Run `mem0 init` first.", + ); process.exit(1); } @@ -163,7 +180,10 @@ export async function claimViaOtp( let detail: string = verifyResp.statusText; let errCode = ""; try { - const errBody = (await verifyResp.json()) as { error?: string; code?: string }; + const errBody = (await verifyResp.json()) as { + error?: string; + code?: string; + }; if (errBody.error) detail = errBody.error; if (errBody.code) errCode = errBody.code; } catch { @@ -178,7 +198,10 @@ export async function claimViaOtp( process.exit(1); } - const body = (await verifyResp.json()) as { claimed?: boolean; claimed_at?: string }; + const body = (await verifyResp.json()) as { + claimed?: boolean; + claimed_at?: string; + }; if (!body.claimed) { printError(`Unexpected verify response: ${JSON.stringify(body)}`); process.exit(1); diff --git a/cli/node/src/commands/init.ts b/cli/node/src/commands/init.ts index 29a422fa3..a00a35430 100644 --- a/cli/node/src/commands/init.ts +++ b/cli/node/src/commands/init.ts @@ -258,7 +258,10 @@ export async function runInit( const { isAgentMode } = await import("../state.js"); const { captureEvent } = await import("../telemetry.js"); - const fireInit = (mode: "agent" | "email" | "api_key" | "existing_key", claimed = false) => { + 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; @@ -286,7 +289,12 @@ export async function runInit( } // ── Claim flow: --email against an existing agent-mode config ─────────── - if (opts.email && fs.existsSync(CONFIG_FILE) && savedConfig.platform.agentMode && savedConfig.platform.apiKey) { + 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}...`); @@ -370,9 +378,7 @@ export async function runInit( // 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; + opts.agent === true || isAgentMode() || detectAgentCaller() !== null; if (!opts.apiKey && !opts.email && agentCtx) { await bootstrapViaBackend(config, { source: opts.source ?? null }); fireInit("agent"); diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index d329a32af..ff9bb61aa 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -13,7 +13,7 @@ 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, takeNotice } from "./state.js"; import { captureEvent } from "./telemetry.js"; import { CLI_VERSION } from "./version.js"; @@ -197,8 +197,15 @@ 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", + "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\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", @@ -777,4 +784,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/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/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/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index 51e170e5a..9c49a2ad0 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -856,7 +856,9 @@ def init( False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)." ), source: str | None = typer.Option( - None, "--source", help="Channel attribution for signup (e.g. github, hn, ph).", + None, + "--source", + help="Channel attribution for signup (e.g. github, hn, ph).", ), ) -> None: """Interactive setup wizard for mem0 CLI. @@ -1215,11 +1217,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/commands/agent_mode_cmd.py b/cli/python/src/mem0_cli/commands/agent_mode_cmd.py index 30a0fbb71..6623ed220 100644 --- a/cli/python/src/mem0_cli/commands/agent_mode_cmd.py +++ b/cli/python/src/mem0_cli/commands/agent_mode_cmd.py @@ -15,7 +15,6 @@ from mem0_cli.branding import ( BRAND_COLOR, DIM_COLOR, print_error, - print_info, print_success, ) from mem0_cli.config import Mem0Config, save_config @@ -81,7 +80,13 @@ def bootstrap_via_backend( save_config(config) print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}") - console.print(f" [{DIM_COLOR}]To claim this account later: {envelope.get('claim_command', 'mem0 init --email ')}[/]") + 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}[/]") def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None: @@ -161,7 +166,9 @@ def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> 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.[/]") + 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) body = verify.json() 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/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/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/docs/llms.txt b/docs/llms.txt index 7ef58b201..83e01df1b 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 --json` + +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/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..9e1bbdec2 100644 --- a/skills/mem0-cli/SKILL.md +++ b/skills/mem0-cli/SKILL.md @@ -38,7 +38,21 @@ 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 --json +``` + +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-integrate/SKILL.md b/skills/mem0-integrate/SKILL.md index 8230a63b3..be747e4ac 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 --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`) to mint an evaluation key without email/dashboard friction. 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..0d51dd569 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 --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:**