Files
rohitg00__agentmemory/test/cli-connect.test.ts
Ross Story a0da02b6b3 Add GitHub Copilot CLI support (#534)
* feat: add Copilot CLI plugin asset slice

- plugin/.plugin/plugin.json: Copilot manifest with name/version/skills/mcpServers/hooks refs
- plugin/.mcp.copilot.json: MCP server config with type:local, npx, env passthrough, tools:[*]
- plugin/hooks/hooks.copilot.json: Copilot hooks (version:1) with 11 supported events and PreToolUse matcher
- test/copilot-plugin.test.ts: 11 tests covering manifest, MCP config, and hooks validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Copilot CLI connect support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add GitHub Copilot CLI support

Adds Copilot CLI support through a root plugin manifest, Copilot-specific MCP and hook configuration, and a connect adapter for MCP-only setup.

Includes Windows-safe Copilot MCP command generation, COPILOT_HOME handling, Copilot hook payload normalization, generated hook scripts, and targeted tests for plugin shape, hook execution, and connect behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Harden Copilot hook handling

Addresses upstream AI review suggestions by aligning the Copilot preToolUse matcher with the hook allowlist, narrowing hook payload fields at runtime, normalizing subagent fallbacks, and tightening hook config validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Copilot to first-run onboarding

Includes GitHub Copilot CLI in the first-run agent picker and adds a regression test so the Copilot setup path remains discoverable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Default onboarding to Copilot inside Copilot CLI

Detect Copilot CLI environment markers during first-run setup so pressing Enter wires the current agent instead of the historical Claude Code default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Support framed stdio MCP transport

Accept Content-Length framed JSON-RPC messages in addition to the existing newline-delimited transport so Copilot CLI can initialize the standalone MCP server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Narrow Copilot pre-tool session ids

Ensures pre-tool-use only forwards string session IDs and falls back to unknown for invalid Copilot payload values, with regression coverage for the generated plugin script.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Ross Story <rostory@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Rohit Ghumare <ghumare64@gmail.com>
2026-05-28 09:54:54 +01:00

420 lines
15 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",
"openclaw",
"openhuman",
"pi",
"qwen",
"warp",
"zed",
].sort(),
);
expect(ADAPTERS.length).toBe(17);
});
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");
}
});
});
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 — 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");
});
});