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
+141
View File
@@ -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<string, string> } = {},
): { 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");
});
});
+168
View File
@@ -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);
});
});