feat(cli): Agent Mode bootstrap + claim flow (Python + Node) (#5123)

This commit is contained in:
Mragank Shekhar
2026-05-14 20:35:25 +05:30
committed by GitHub
parent 70bc9e51d5
commit e602923751
41 changed files with 2424 additions and 26 deletions
+285
View File
@@ -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 <name>`. 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<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
const body: Record<string, unknown> = {};
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 <your-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 <your-name>` (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<void> {
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 <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 <key>` 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<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${label}: `, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
+75
View File
@@ -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 <name>` 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<void> {
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}.`);
}
+167 -1
View File
@@ -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<boolean> {
// 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<void> {
// 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<void> {
process.exit(1);
}
config.platform.apiKey = apiKey;
config.platform.createdVia = "api_key";
}
async function setupDefaults(config: Mem0Config): Promise<void> {
@@ -249,14 +311,35 @@ export async function runInit(
email?: string;
code?: string;
force?: boolean;
agent?: boolean;
source?: string;
agentCaller?: string;
} = {},
): Promise<void> {
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<string, unknown> = { 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 <key> [--user-id <id>]",
"Usage: mem0 init --api-key <key>, --email <addr>, 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";