Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

504 lines
18 KiB
TypeScript

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");
});
});