Files

135 lines
8.1 KiB
TypeScript

/**
* 覆盖 Agent 配置适配器、Skill 目录解析、幂等配置、冲突替换与备份行为。
*
* @packageDocumentation
*/
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { parse } from "jsonc-parser";
import { configureAgents, type CommandResult, type CommandRunner, defaultSkillPath, launchCommand, SpawnCommandRunner, unconfigureAgents } from "../src/agents.js";
import { resolveClientOptions } from "../src/config.js";
class FakeRunner implements CommandRunner {
readonly calls: Array<{ command: string; args: string[] }> = [];
existing: Record<string, string | undefined> = {};
async run(command: string, args: string[]): Promise<CommandResult> {
this.calls.push({ command, args });
const agent = command === "codex" ? "codex" : "claude";
if (args[1] === "get") {
const value = this.existing[agent];
return value === undefined
? { code: 1, stdout: "", stderr: "MCP server not found" }
: { code: 0, stdout: value, stderr: "" };
}
if (args[1] === "remove") {
this.existing[agent] = undefined;
return { code: 0, stdout: "", stderr: "" };
}
if (args[1] === "add") {
const separator = args.indexOf("--");
const commandParts = args.slice(separator + 1);
this.existing[agent] = JSON.stringify({ transport: { command: commandParts[0], args: commandParts.slice(1) } });
return { code: 0, stdout: "", stderr: "" };
}
return { code: 1, stdout: "", stderr: "unexpected command" };
}
}
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
const cliEntry = path.resolve("..", "build", "mcp-client", "cli.js");
const nodePath = path.resolve("bin", "node.exe");
test("Agent Skill paths use each client's user-level discovery directory", () => {
const env = { USERPROFILE: "C:\\Users\\tester", CODEX_HOME: "C:\\CodexHome", XDG_CONFIG_HOME: "C:\\Config" };
assert.equal(defaultSkillPath("codex", env), path.join("C:\\CodexHome", "skills", "crafttable"));
assert.equal(defaultSkillPath("claude", env), path.join("C:\\Users\\tester", ".claude", "skills", "crafttable"));
assert.equal(defaultSkillPath("opencode", env), path.join("C:\\Config", "opencode", "skills", "crafttable"));
});
test("command runner launches Windows command shims", { skip: process.platform !== "win32" }, async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "crafttable-mcp-shim-test-"));
const shim = path.join(directory, "fixture.cmd");
await writeFile(shim, "@echo off\r\necho shim:%1\r\n", "utf8");
try {
const result = await new SpawnCommandRunner().run(shim, ["ok"]);
assert.equal(result.code, 0);
assert.match(result.stdout, /shim:"?ok"?/);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("Codex and Claude adapters add, detect idempotency, replace conflicts, remove, and dry-run", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "crafttable-mcp-cli-agent-test-"));
const skillSource = await createSkillFixture(directory);
const codexSkill = path.join(directory, "codex", "crafttable");
const claudeSkill = path.join(directory, "claude", "crafttable");
const runner = new FakeRunner();
const input = { options, cliEntry, nodePath, runner, skillSource, skillPaths: { codex: codexSkill, claude: claudeSkill } };
try {
assert.deepEqual(await configureAgents({ ...input, target: "codex" }), [{ agent: "codex", action: "added", skillAction: "installed" }]);
assert.match(await readFile(path.join(codexSkill, "SKILL.md"), "utf8"), /name: crafttable/);
assert.deepEqual(await configureAgents({ ...input, target: "codex" }), [{ agent: "codex", action: "unchanged", skillAction: "unchanged" }]);
runner.existing.codex = JSON.stringify({ transport: { command: "other", args: [] } });
await assert.rejects(configureAgents({ ...input, target: "codex", confirm: async () => false }), /not changed/);
assert.deepEqual(await configureAgents({ ...input, target: "codex", force: true }), [{ agent: "codex", action: "replaced", skillAction: "unchanged" }]);
await writeFile(path.join(codexSkill, "SKILL.md"), "locally modified\n", "utf8");
await assert.rejects(configureAgents({ ...input, target: "codex", confirm: async () => false }), /Skill configuration was not changed/);
assert.deepEqual(await configureAgents({ ...input, target: "codex", force: true }), [{ agent: "codex", action: "unchanged", skillAction: "replaced" }]);
assert.equal(await readFile(path.join(codexSkill, "SKILL.md"), "utf8"), await readFile(path.join(skillSource, "SKILL.md"), "utf8"));
assert.equal(await readFile(`${codexSkill}.crafttable-mcp.backup/SKILL.md`, "utf8"), "locally modified\n");
assert.deepEqual(await unconfigureAgents({ ...input, target: "codex", dryRun: true }), [{ agent: "codex", action: "would-remove", skillAction: "would-remove" }]);
assert.deepEqual(await unconfigureAgents({ ...input, target: "codex" }), [{ agent: "codex", action: "removed", skillAction: "removed" }]);
assert.deepEqual(await configureAgents({ ...input, target: "claude", dryRun: true }), [{ agent: "claude", action: "would-add", skillAction: "would-install" }]);
assert.equal(runner.existing.claude, undefined);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("OpenCode adapter preserves JSONC, creates one backup, is idempotent, and unconfigures", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "crafttable-mcp-agent-test-"));
const filePath = path.join(directory, "opencode.json");
const skillSource = await createSkillFixture(directory);
const opencodeSkill = path.join(directory, "opencode", "skills", "crafttable");
const input = { target: "opencode" as const, options, cliEntry, nodePath, opencodePath: filePath, skillSource, skillPaths: { opencode: opencodeSkill } };
await writeFile(filePath, "{\n // keep this comment\n \"theme\": \"dark\"\n}\n", "utf8");
try {
assert.deepEqual(await configureAgents(input), [{ agent: "opencode", action: "added", skillAction: "installed" }]);
const configuredText = await readFile(filePath, "utf8");
assert.match(configuredText, /keep this comment/);
const configured = parse(configuredText) as { theme: string; mcp: Record<string, { type: string; command: string[] }> };
assert.equal(configured.theme, "dark");
assert.equal(configured.mcp.crafttable?.type, "local");
assert.deepEqual(configured.mcp.crafttable?.command, launchCommand(options, cliEntry, nodePath));
assert.match(await readFile(`${filePath}.crafttable-mcp.backup`, "utf8"), /keep this comment/);
assert.deepEqual(await configureAgents(input), [{ agent: "opencode", action: "unchanged", skillAction: "unchanged" }]);
await writeFile(path.join(opencodeSkill, "SKILL.md"), "user customization\n", "utf8");
assert.deepEqual(await unconfigureAgents(input), [{ agent: "opencode", action: "removed", skillAction: "preserved" }]);
const removed = parse(await readFile(filePath, "utf8")) as { mcp?: Record<string, unknown> };
assert.equal(removed.mcp?.crafttable, undefined);
assert.equal(await readFile(path.join(opencodeSkill, "SKILL.md"), "utf8"), "user customization\n");
assert.deepEqual(await unconfigureAgents({ ...input, force: true }), [{ agent: "opencode", action: "absent", skillAction: "removed" }]);
assert.equal(await readFile(`${opencodeSkill}.crafttable-mcp.backup/SKILL.md`, "utf8"), "user customization\n");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
async function createSkillFixture(directory: string): Promise<string> {
const skillSource = path.join(directory, "bundled-skill");
await mkdir(path.join(skillSource, "agents"), { recursive: true });
await writeFile(path.join(skillSource, "SKILL.md"), "---\nname: crafttable\ndescription: Use CraftTable\n---\n\nUse the crafttable MCP.\n", "utf8");
await writeFile(path.join(skillSource, "agents", "openai.yaml"), "policy:\n allow_implicit_invocation: true\n", "utf8");
return skillSource;
}