chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import {
|
||||
ADAPTERS,
|
||||
knownAgents,
|
||||
resolveAdapter,
|
||||
} from "../src/cli/connect/index.js";
|
||||
import type { ConnectAdapter } from "../src/cli/connect/types.js";
|
||||
|
||||
const EXPECTED_COPILOT_MCP_COMMAND =
|
||||
process.platform === "win32"
|
||||
? {
|
||||
command: process.env["ComSpec"] || process.env["COMSPEC"] || "cmd.exe",
|
||||
args: ["/d", "/s", "/c", "npx", "-y", "@agentmemory/mcp"],
|
||||
}
|
||||
: {
|
||||
command: "npx",
|
||||
args: ["-y", "@agentmemory/mcp"],
|
||||
};
|
||||
|
||||
describe("agentmemory connect — dispatcher", () => {
|
||||
it("resolves every known agent by lowercase name", () => {
|
||||
for (const name of knownAgents()) {
|
||||
const a = resolveAdapter(name);
|
||||
expect(a, `expected adapter for ${name}`).not.toBeNull();
|
||||
expect(a!.name).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves case-insensitively", () => {
|
||||
expect(resolveAdapter("Claude-Code")?.name).toBe("claude-code");
|
||||
expect(resolveAdapter("CURSOR")?.name).toBe("cursor");
|
||||
});
|
||||
|
||||
it("returns null for unknown agents", () => {
|
||||
expect(resolveAdapter("nonexistent-agent")).toBeNull();
|
||||
expect(resolveAdapter("")).toBeNull();
|
||||
});
|
||||
|
||||
it("ships the supported agent list", () => {
|
||||
expect(knownAgents().sort()).toEqual(
|
||||
[
|
||||
"antigravity",
|
||||
"claude-code",
|
||||
"cline",
|
||||
"copilot-cli",
|
||||
"codex",
|
||||
"continue",
|
||||
"cursor",
|
||||
"droid",
|
||||
"gemini-cli",
|
||||
"hermes",
|
||||
"kiro",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"openhuman",
|
||||
"pi",
|
||||
"qwen",
|
||||
"warp",
|
||||
"zed",
|
||||
].sort(),
|
||||
);
|
||||
expect(ADAPTERS.length).toBe(18);
|
||||
});
|
||||
|
||||
it("every adapter exposes detect() and install()", () => {
|
||||
for (const a of ADAPTERS) {
|
||||
expect(typeof a.detect).toBe("function");
|
||||
expect(typeof a.install).toBe("function");
|
||||
expect(typeof a.name).toBe("string");
|
||||
expect(typeof a.displayName).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("every adapter declares a category so onboarding never needs a separate list (#872)", () => {
|
||||
for (const a of ADAPTERS) {
|
||||
expect(
|
||||
["native", "mcp"].includes(a.category as string),
|
||||
`adapter ${a.name} must set category to "native" or "mcp"`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentmemory connect — claude-code adapter (mock filesystem)", () => {
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserprofile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), "am-connect-"));
|
||||
originalHome = process.env["HOME"];
|
||||
originalUserprofile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tmpHome;
|
||||
process.env["USERPROFILE"] = tmpHome;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome !== undefined) process.env["HOME"] = originalHome;
|
||||
else delete process.env["HOME"];
|
||||
if (originalUserprofile !== undefined)
|
||||
process.env["USERPROFILE"] = originalUserprofile;
|
||||
else delete process.env["USERPROFILE"];
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function loadAdapter(): Promise<ConnectAdapter> {
|
||||
const mod = await import("../src/cli/connect/claude-code.js?t=" + Date.now());
|
||||
return (mod as { adapter: ConnectAdapter }).adapter;
|
||||
}
|
||||
|
||||
it("detect() returns false when ~/.claude doesn't exist", async () => {
|
||||
const a = await loadAdapter();
|
||||
expect(a.detect()).toBe(false);
|
||||
});
|
||||
|
||||
it("install() writes mcpServers.agentmemory into ~/.claude.json and is idempotent", async () => {
|
||||
const claudeDir = join(tmpHome, ".claude");
|
||||
require("node:fs").mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmpHome, ".claude.json"),
|
||||
JSON.stringify({ mcpServers: { other: { command: "x" } } }),
|
||||
);
|
||||
|
||||
const a = await loadAdapter();
|
||||
expect(a.detect()).toBe(true);
|
||||
|
||||
const first = await a.install({ dryRun: false, force: false });
|
||||
expect(first.kind).toBe("installed");
|
||||
|
||||
const config = JSON.parse(readFileSync(join(tmpHome, ".claude.json"), "utf-8"));
|
||||
expect(config.mcpServers.agentmemory.command).toBe("npx");
|
||||
expect(config.mcpServers.agentmemory.args).toContain("@agentmemory/mcp");
|
||||
expect(config.mcpServers.other.command).toBe("x");
|
||||
|
||||
const second = await a.install({ dryRun: false, force: false });
|
||||
expect(second.kind).toBe("already-wired");
|
||||
});
|
||||
|
||||
it("install() writes env passthrough block for AGENTMEMORY_URL + AGENTMEMORY_SECRET (#375)", async () => {
|
||||
// Remote deployments (k8s, reverse proxy) set AGENTMEMORY_URL +
|
||||
// AGENTMEMORY_SECRET in the shell. The wired MCP entry must honour
|
||||
// those via ${VAR} expansion so a single entry covers both local
|
||||
// and remote without the user needing to add a duplicate config
|
||||
// that triggers a /doctor duplicate-server warning.
|
||||
const claudeDir = join(tmpHome, ".claude");
|
||||
require("node:fs").mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(join(tmpHome, ".claude.json"), JSON.stringify({}));
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
|
||||
const config = JSON.parse(readFileSync(join(tmpHome, ".claude.json"), "utf-8"));
|
||||
const entry = config.mcpServers.agentmemory;
|
||||
expect(entry.env).toBeDefined();
|
||||
// env interpolation must carry a default so Claude Code
|
||||
// doesn't silently drop the server when the user hasn't exported
|
||||
// AGENTMEMORY_URL / AGENTMEMORY_SECRET. Defaults match the
|
||||
// documented runtime (localhost:3111, no auth, all tools).
|
||||
expect(entry.env.AGENTMEMORY_URL).toBe(
|
||||
"${AGENTMEMORY_URL:-http://localhost:3111}",
|
||||
);
|
||||
expect(entry.env.AGENTMEMORY_SECRET).toBe("${AGENTMEMORY_SECRET:-}");
|
||||
expect(entry.env.AGENTMEMORY_TOOLS).toBe("${AGENTMEMORY_TOOLS:-all}");
|
||||
});
|
||||
|
||||
it("install() with --force re-writes even when already wired", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmpHome, ".claude.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
agentmemory: { command: "npx", args: ["-y", "@agentmemory/mcp"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: false, force: true });
|
||||
expect(result.kind).toBe("installed");
|
||||
});
|
||||
|
||||
it("install() with --dry-run does not mutate the file", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true });
|
||||
const before = JSON.stringify({ mcpServers: {} });
|
||||
writeFileSync(join(tmpHome, ".claude.json"), before);
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: true, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
|
||||
const after = readFileSync(join(tmpHome, ".claude.json"), "utf-8");
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("install() creates a backup file under ~/.agentmemory/backups/", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmpHome, ".claude.json"),
|
||||
JSON.stringify({ mcpServers: {} }),
|
||||
);
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
if (result.kind === "installed") {
|
||||
expect(result.backupPath).toBeDefined();
|
||||
expect(existsSync(result.backupPath!)).toBe(true);
|
||||
expect(result.backupPath!).toContain(join(".agentmemory", "backups"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentmemory connect — opencode adapter (#872)", () => {
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserprofile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), "am-opencode-"));
|
||||
originalHome = process.env["HOME"];
|
||||
originalUserprofile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tmpHome;
|
||||
process.env["USERPROFILE"] = tmpHome;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome !== undefined) process.env["HOME"] = originalHome;
|
||||
else delete process.env["HOME"];
|
||||
if (originalUserprofile !== undefined)
|
||||
process.env["USERPROFILE"] = originalUserprofile;
|
||||
else delete process.env["USERPROFILE"];
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const cfgPath = () =>
|
||||
join(tmpHome, ".config", "opencode", "opencode.json");
|
||||
|
||||
async function loadOpencode(): Promise<ConnectAdapter> {
|
||||
const mod = await import("../src/cli/connect/opencode.js?t=" + Date.now());
|
||||
return (mod as { adapter: ConnectAdapter }).adapter;
|
||||
}
|
||||
|
||||
it("writes the opencode `mcp` schema (command as array) and preserves other servers", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".config", "opencode"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
cfgPath(),
|
||||
JSON.stringify({ mcp: { other: { type: "local", command: ["x"] } } }),
|
||||
);
|
||||
|
||||
const a = await loadOpencode();
|
||||
expect(a.name).toBe("opencode");
|
||||
expect(a.detect()).toBe(true);
|
||||
|
||||
const first = await a.install({ dryRun: false, force: false });
|
||||
expect(first.kind).toBe("installed");
|
||||
|
||||
const config = JSON.parse(readFileSync(cfgPath(), "utf-8"));
|
||||
const entry = config.mcp.agentmemory;
|
||||
expect(entry.type).toBe("local");
|
||||
expect(Array.isArray(entry.command)).toBe(true);
|
||||
expect(entry.command).toContain("@agentmemory/mcp");
|
||||
expect(entry.enabled).toBe(true);
|
||||
expect(config.mcp.other.command).toEqual(["x"]);
|
||||
|
||||
const second = await a.install({ dryRun: false, force: false });
|
||||
expect(second.kind).toBe("already-wired");
|
||||
});
|
||||
|
||||
it("dry-run does not mutate the file", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".config", "opencode"), {
|
||||
recursive: true,
|
||||
});
|
||||
const before = JSON.stringify({ mcp: {} });
|
||||
writeFileSync(cfgPath(), before);
|
||||
|
||||
const a = await loadOpencode();
|
||||
const result = await a.install({ dryRun: true, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
expect(readFileSync(cfgPath(), "utf-8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentmemory connect — copilot-cli adapter (mock filesystem)", () => {
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserprofile: string | undefined;
|
||||
let originalCopilotHome: string | undefined;
|
||||
let importCounter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), "am-connect-"));
|
||||
originalHome = process.env["HOME"];
|
||||
originalUserprofile = process.env["USERPROFILE"];
|
||||
originalCopilotHome = process.env["COPILOT_HOME"];
|
||||
process.env["HOME"] = tmpHome;
|
||||
process.env["USERPROFILE"] = tmpHome;
|
||||
delete process.env["COPILOT_HOME"];
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome !== undefined) process.env["HOME"] = originalHome;
|
||||
else delete process.env["HOME"];
|
||||
if (originalUserprofile !== undefined)
|
||||
process.env["USERPROFILE"] = originalUserprofile;
|
||||
else delete process.env["USERPROFILE"];
|
||||
if (originalCopilotHome !== undefined)
|
||||
process.env["COPILOT_HOME"] = originalCopilotHome;
|
||||
else delete process.env["COPILOT_HOME"];
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function loadAdapter(): Promise<ConnectAdapter> {
|
||||
const mod = await import(
|
||||
"../src/cli/connect/copilot-cli.js?t=" + Date.now() + "-" + importCounter++
|
||||
);
|
||||
return (mod as { adapter: ConnectAdapter }).adapter;
|
||||
}
|
||||
|
||||
it("detect() returns false when ~/.copilot doesn't exist", async () => {
|
||||
const a = await loadAdapter();
|
||||
expect(a.detect()).toBe(false);
|
||||
});
|
||||
|
||||
it("install() writes mcpServers.agentmemory into ~/.copilot/mcp-config.json and is idempotent", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
|
||||
|
||||
const a = await loadAdapter();
|
||||
expect(a.detect()).toBe(true);
|
||||
|
||||
const first = await a.install({ dryRun: false, force: false });
|
||||
expect(first.kind).toBe("installed");
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"),
|
||||
);
|
||||
expect(config.mcpServers.agentmemory).toEqual({
|
||||
type: "local",
|
||||
...EXPECTED_COPILOT_MCP_COMMAND,
|
||||
env: {
|
||||
AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}",
|
||||
AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}",
|
||||
AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}",
|
||||
},
|
||||
tools: ["*"],
|
||||
});
|
||||
|
||||
const second = await a.install({ dryRun: false, force: false });
|
||||
expect(second.kind).toBe("already-wired");
|
||||
});
|
||||
|
||||
it("honors COPILOT_HOME when locating mcp-config.json", async () => {
|
||||
const customCopilotHome = join(tmpHome, "custom-copilot-home");
|
||||
process.env["COPILOT_HOME"] = customCopilotHome;
|
||||
require("node:fs").mkdirSync(customCopilotHome, { recursive: true });
|
||||
|
||||
const a = await loadAdapter();
|
||||
expect(a.detect()).toBe(true);
|
||||
|
||||
const result = await a.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
expect(result.mutatedPath).toBe(join(customCopilotHome, "mcp-config.json"));
|
||||
expect(existsSync(join(customCopilotHome, "mcp-config.json"))).toBe(true);
|
||||
expect(existsSync(join(tmpHome, ".copilot", "mcp-config.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("install() preserves unrelated top-level keys and mcpServers entries", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmpHome, ".copilot", "mcp-config.json"),
|
||||
JSON.stringify({
|
||||
otherTopLevel: { keep: true },
|
||||
mcpServers: { other: { type: "local", command: "other" } },
|
||||
}),
|
||||
);
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"),
|
||||
);
|
||||
expect(config.otherTopLevel).toEqual({ keep: true });
|
||||
expect(config.mcpServers.other).toEqual({ type: "local", command: "other" });
|
||||
expect(config.mcpServers.agentmemory.command).toBe(
|
||||
EXPECTED_COPILOT_MCP_COMMAND.command,
|
||||
);
|
||||
});
|
||||
|
||||
it("install() writes env passthrough block for AGENTMEMORY_URL + AGENTMEMORY_SECRET", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"),
|
||||
);
|
||||
const entry = config.mcpServers.agentmemory;
|
||||
expect(entry.env.AGENTMEMORY_URL).toBe(
|
||||
"${AGENTMEMORY_URL:-http://localhost:3111}",
|
||||
);
|
||||
expect(entry.env.AGENTMEMORY_SECRET).toBe("${AGENTMEMORY_SECRET:-}");
|
||||
expect(entry.env.AGENTMEMORY_TOOLS).toBe("${AGENTMEMORY_TOOLS:-all}");
|
||||
});
|
||||
|
||||
it("install() with --force rewrites even when already wired", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmpHome, ".copilot", "mcp-config.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
agentmemory: {
|
||||
type: "local",
|
||||
...EXPECTED_COPILOT_MCP_COMMAND,
|
||||
env: {
|
||||
AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}",
|
||||
AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}",
|
||||
AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}",
|
||||
},
|
||||
tools: ["memory_save"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: false, force: true });
|
||||
expect(result.kind).toBe("installed");
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"),
|
||||
);
|
||||
expect(config.mcpServers.agentmemory.tools).toEqual(["*"]);
|
||||
});
|
||||
|
||||
it("install() with --dry-run does not mutate the file", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
|
||||
const before = JSON.stringify({ mcpServers: {} });
|
||||
writeFileSync(join(tmpHome, ".copilot", "mcp-config.json"), before);
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: true, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
|
||||
const after = readFileSync(
|
||||
join(tmpHome, ".copilot", "mcp-config.json"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("install() creates a backup file when config pre-exists", async () => {
|
||||
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmpHome, ".copilot", "mcp-config.json"),
|
||||
JSON.stringify({ mcpServers: {} }),
|
||||
);
|
||||
|
||||
const a = await loadAdapter();
|
||||
const result = await a.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("installed");
|
||||
if (result.kind === "installed") {
|
||||
expect(result.backupPath).toBeDefined();
|
||||
expect(existsSync(result.backupPath!)).toBe(true);
|
||||
expect(result.backupPath!).toContain(join(".agentmemory", "backups"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentmemory connect — stub adapters log + return stub", () => {
|
||||
it("hermes adapter returns stub regardless of detect", async () => {
|
||||
const { adapter } = await import("../src/cli/connect/hermes.js");
|
||||
const result = await adapter.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("stub");
|
||||
});
|
||||
|
||||
it("openhuman adapter returns stub", async () => {
|
||||
const { adapter } = await import("../src/cli/connect/openhuman.js");
|
||||
const result = await adapter.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("stub");
|
||||
});
|
||||
|
||||
it("pi adapter returns stub", async () => {
|
||||
const { adapter } = await import("../src/cli/connect/pi.js");
|
||||
const result = await adapter.install({ dryRun: false, force: false });
|
||||
expect(result.kind).toBe("stub");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user