diff --git a/README.md b/README.md index a1d4dce..ef872e5 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,18 @@ longer exist. Until the skills are published to npm, point install at a local checkout with `--skills-dir` or `GAFFA_SKILLS_DIR`. +The same run also registers the Gaffa docs MCP server (`https://gaffa.dev/docs/~gitbook/mcp`) +in each tool's own MCP config, merging into an existing config without touching +your other servers. A config it cannot parse is backed up and left alone. Pass +`--no-mcp` to skip this. At project scope Claude Code will ask you to approve the +server the first time you use it. + ### uninstall -`uninstall` removes the skills a previous install wrote, for a scope. A skill you -have edited since is left in place and reported, so your own changes are never -lost. +`uninstall` removes the skills a previous install wrote, for a scope, and the +docs MCP server it registered. A skill you have edited since is left in place and +reported, so your own changes are never lost. An MCP entry you have re-pointed +elsewhere is left alone. Pass `--no-mcp` to keep the server registered. ``` npx @gaffa-dev/cli uninstall --scope=project diff --git a/src/cli.ts b/src/cli.ts index 73a61b5..e9128c5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { runDoctor, processContext } from "./doctor.js"; import { inspectTools, TOOLS, type DoctorContext, type Scope } from "./tools.js"; import { fetchNpmSource, readLocalSource } from "./skills-source.js"; import { install, uninstall, type InstallResult, type UninstallResult } from "./install.js"; +import { registerMcp, unregisterMcp, type McpResult } from "./mcp.js"; const pkg = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf8"), @@ -22,15 +23,18 @@ Commands doctor Report which AI coding tools are installed and whether the gaffa skills are set up in them. Add --json for machine output. install Fetch the latest gaffa skills from npm and copy them into the - tools you pick. + tools you pick, and register the gaffa docs MCP server in each. --tools=a,b tool ids, default the installed ones --scope=project or personal, default project --skills-dir=PATH read the skills from a local checkout instead of npm, or set GAFFA_SKILLS_DIR + --no-mcp skip registering the docs MCP server -y, --yes take the defaults, do not prompt - uninstall Remove skills a previous install wrote, for a scope. A skill you - edited since is left in place and reported. + uninstall Remove skills a previous install wrote, for a scope, and the + docs MCP server. A skill you edited since is left in place and + reported. --scope=project or personal, default project + --no-mcp leave the docs MCP server in place -y, --yes take the defaults, do not prompt Options @@ -123,6 +127,45 @@ function formatUninstall(results: UninstallResult[], ctx: DoctorContext): string return lines.join("\n") + "\n"; } +// The human words for each outcome, kept short. +const MCP_WORDS: Record = { + added: "added", + updated: "updated", + unchanged: "already set", + removed: "removed", + refused: "could not parse, skipped", + skipped: "skipped", +}; + +function formatMcpRegister(results: McpResult[], ctx: DoctorContext): string { + if (results.length === 0) return ""; + const lines = ["", "Registered the gaffa-docs MCP server:"]; + for (const r of results) { + const detail = r.detail ? ` (${r.detail})` : ""; + lines.push(` ${r.label} ${shortPath(r.path, ctx)} ${MCP_WORDS[r.outcome]}${detail}`); + } + // Claude Code prompts for approval of a project-scoped server on first use. + const claudeProject = results.some( + (r) => r.toolId === "claude-code" && r.scope === "project" && (r.outcome === "added" || r.outcome === "updated"), + ); + if (claudeProject) { + lines.push(""); + lines.push("Claude Code will ask you to approve the project MCP server the first time you use it."); + } + return lines.join("\n") + "\n"; +} + +function formatMcpUnregister(results: McpResult[], ctx: DoctorContext): string { + const touched = results.filter((r) => r.outcome !== "skipped" || r.detail); + if (touched.length === 0) return ""; + const lines = ["", "gaffa-docs MCP server:"]; + for (const r of touched) { + const detail = r.detail ? ` (${r.detail})` : ""; + lines.push(` ${r.label} ${shortPath(r.path, ctx)} ${MCP_WORDS[r.outcome]}${detail}`); + } + return lines.join("\n") + "\n"; +} + async function runInstall(flags: Flags): Promise { const ctx = processContext(); const interactive = Boolean(process.stdin.isTTY) && !flags.bools.has("yes"); @@ -169,6 +212,9 @@ async function runInstall(flags: Flags): Promise { } process.stdout.write(formatInstall(install(ctx, { tools, scope, source }), source.version, ctx)); + if (!flags.bools.has("no-mcp")) { + process.stdout.write(formatMcpRegister(registerMcp(ctx, { tools, scope }), ctx)); + } return 0; } @@ -185,6 +231,9 @@ async function runUninstall(flags: Flags): Promise { } process.stdout.write(formatUninstall(uninstall(ctx, scope), ctx)); + if (!flags.bools.has("no-mcp")) { + process.stdout.write(formatMcpUnregister(unregisterMcp(ctx, scope), ctx)); + } return 0; } diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..deeae04 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,251 @@ +// Register the Gaffa docs MCP server in each tool's own MCP config, and remove +// it again on uninstall. +// +// The same install that writes the skills registers the docs MCP at every +// selected tool that has a known MCP config location for the chosen scope. Each +// tool keeps its servers differently (see tools.ts): four use JSON under a +// `mcpServers` key, Codex uses TOML under `[mcp_servers.NAME]`, and the field +// carrying the URL is `url` for most but `serverUrl` for Antigravity. +// +// We own the name `gaffa-docs`, so a register overwrites our own entry (an +// idempotent refresh) and an uninstall removes it only when it still points at +// our URL, so a server the user re-pointed is left alone. A JSON file we cannot +// parse is backed up and left untouched rather than clobbered. Codex TOML has no +// parser here, so those edits are surgical: append our table, or replace the one +// we recognise by its header. + +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { TOOLS, mcpTarget, type DoctorContext, type McpTarget, type Scope } from "./tools.js"; + +export const MCP_NAME = "gaffa-docs"; +export const MCP_URL = "https://gaffa.dev/docs/~gitbook/mcp"; + +export type McpOutcome = "added" | "updated" | "unchanged" | "removed" | "refused" | "skipped"; + +export interface McpResult { + toolId: string; + label: string; + scope: Scope; + path: string; + outcome: McpOutcome; + // Extra context: the backup path on a refusal, or why we skipped or noted. + detail?: string; +} + +// The server entry we write, in the shape the tool expects. +function buildEntry(target: McpTarget): Record { + const entry: Record = {}; + if (target.type) entry.type = target.type; + entry[target.urlKey] = MCP_URL; + if (target.extra) Object.assign(entry, target.extra); + return entry; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readTextOrNull(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +function writeText(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +} + +// A parsed JSON config object, or "malformed" when the text is not a JSON object +// we can safely edit (invalid JSON, or an mcpServers that is not an object). +type ParsedJson = { obj: Record; servers: Record } | "malformed"; + +function parseJson(text: string | null): ParsedJson { + if (text === null || text.trim() === "") return { obj: {}, servers: {} }; + let obj: unknown; + try { + obj = JSON.parse(text); + } catch { + return "malformed"; + } + if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return "malformed"; + const record = obj as Record; + const existing = record.mcpServers; + if (existing === undefined) return { obj: record, servers: {} }; + if (typeof existing !== "object" || existing === null || Array.isArray(existing)) return "malformed"; + return { obj: record, servers: existing as Record }; +} + +function backup(path: string, text: string): string { + const bak = `${path}.gaffa.bak`; + writeFileSync(bak, text); + return bak; +} + +function registerJson(target: McpTarget): Omit { + const text = readTextOrNull(target.path); + const parsed = parseJson(text); + if (parsed === "malformed") { + const bak = backup(target.path, text ?? ""); + return { path: target.path, outcome: "refused", detail: `left it, backed up to ${bak}` }; + } + const { obj, servers } = parsed; + const entry = buildEntry(target); + const existing = servers[MCP_NAME]; + const existingObj = isObject(existing) ? existing : undefined; + const collision = existingObj !== undefined && existingObj[target.urlKey] !== MCP_URL; + if (existing !== undefined && JSON.stringify(existing) === JSON.stringify(entry)) { + return { path: target.path, outcome: "unchanged" }; + } + servers[MCP_NAME] = entry; + obj.mcpServers = servers; + writeText(target.path, JSON.stringify(obj, null, 2) + "\n"); + return { + path: target.path, + outcome: existing === undefined ? "added" : "updated", + detail: collision ? "replaced a different gaffa-docs entry" : undefined, + }; +} + +// Codex TOML. We only ever write our own table, and only touch our own on edits. +const TOML_HEADER = `[mcp_servers.${MCP_NAME}]`; + +function tomlBlock(): string { + return `${TOML_HEADER}\nurl = "${MCP_URL}"\n`; +} + +// A table header line with any trailing comment and surrounding whitespace +// stripped, or null if the line is not a table header. +function tableHeader(line: string): string | null { + if (!line.trimStart().startsWith("[")) return null; + const hash = line.indexOf("#"); + return (hash === -1 ? line : line.slice(0, hash)).trim(); +} + +// Our table, tolerating the equivalent quoted-key spelling of the same name. +function isOurHeader(line: string): boolean { + const h = tableHeader(line); + return h === TOML_HEADER || h === `[mcp_servers."${MCP_NAME}"]`; +} + +// A sub-table of ours, e.g. [mcp_servers.gaffa-docs.http_headers]. +function isOurSubHeader(line: string): boolean { + const h = tableHeader(line); + return h !== null && (h.startsWith(`[mcp_servers.${MCP_NAME}.`) || h.startsWith(`[mcp_servers."${MCP_NAME}".`)); +} + +// The line range [start, end) covering our table and any sub-tables of it. end +// stops before the next unrelated section and does not swallow trailing blank or +// comment lines, which belong to whatever follows. +function findTomlTable(lines: string[]): { start: number; end: number } | null { + const start = lines.findIndex(isOurHeader); + if (start === -1) return null; + let end = start + 1; + while (end < lines.length) { + if (tableHeader(lines[end]) !== null && !isOurHeader(lines[end]) && !isOurSubHeader(lines[end])) break; + end++; + } + while (end > start + 1 && (lines[end - 1].trim() === "" || lines[end - 1].trim().startsWith("#"))) end--; + return { start, end }; +} + +function registerToml(target: McpTarget): Omit { + const text = readTextOrNull(target.path); + const block = tomlBlock(); + if (text === null || text.trim() === "") { + writeText(target.path, block); + return { path: target.path, outcome: "added" }; + } + const lines = text.split("\n"); + const range = findTomlTable(lines); + if (!range) { + const trimmed = text.replace(/\s*$/, ""); + writeText(target.path, `${trimmed}\n\n${block}`); + return { path: target.path, outcome: "added" }; + } + const current = lines.slice(range.start, range.end).join("\n").trimEnd(); + if (current === block.trimEnd()) return { path: target.path, outcome: "unchanged" }; + const tail = lines.slice(range.end); + // Keep a blank line between our table and a following section. + const sep = tail.length && tail[0].trimStart().startsWith("[") ? [""] : []; + const next = [...lines.slice(0, range.start), ...block.trimEnd().split("\n"), ...sep, ...tail]; + const out = next.join("\n"); + writeText(target.path, out.endsWith("\n") ? out : out + "\n"); + return { path: target.path, outcome: "updated" }; +} + +function unregisterJson(target: McpTarget): Omit { + const text = readTextOrNull(target.path); + if (text === null) return { path: target.path, outcome: "skipped" }; + const parsed = parseJson(text); + if (parsed === "malformed") return { path: target.path, outcome: "skipped", detail: "could not parse it, left it" }; + const { obj, servers } = parsed; + const existing = servers[MCP_NAME]; + if (existing === undefined) return { path: target.path, outcome: "skipped" }; + if (!isObject(existing) || existing[target.urlKey] !== MCP_URL) { + return { path: target.path, outcome: "skipped", detail: "left a gaffa-docs that points elsewhere" }; + } + delete servers[MCP_NAME]; + if (Object.keys(servers).length === 0) delete obj.mcpServers; + if (Object.keys(obj).length === 0) rmSync(target.path, { force: true }); + else writeText(target.path, JSON.stringify(obj, null, 2) + "\n"); + return { path: target.path, outcome: "removed" }; +} + +function unregisterToml(target: McpTarget): Omit { + const text = readTextOrNull(target.path); + if (text === null) return { path: target.path, outcome: "skipped" }; + const lines = text.split("\n"); + const range = findTomlTable(lines); + if (!range) return { path: target.path, outcome: "skipped" }; + const block = lines.slice(range.start, range.end).join("\n"); + if (!block.includes(`"${MCP_URL}"`)) { + return { path: target.path, outcome: "skipped", detail: "left a gaffa-docs that points elsewhere" }; + } + // Remove only our table, plus the single blank separator line we added before + // it on append. The rest of the user's config is left untouched, apart from + // normalising the file to a single trailing newline. + let start = range.start; + if (start > 0 && lines[start - 1].trim() === "") start--; + const rest = [...lines.slice(0, start), ...lines.slice(range.end)].join("\n"); + if (rest.trim() === "") rmSync(target.path, { force: true }); + else writeText(target.path, rest.endsWith("\n") ? rest : rest + "\n"); + return { path: target.path, outcome: "removed" }; +} + +export interface RegisterOptions { + tools: string[]; + scope: Scope; +} + +// Register the docs MCP for the selected tools at a scope. A tool with no MCP +// location for that scope is left out of the results, not reported as skipped. +export function registerMcp(ctx: DoctorContext, opts: RegisterOptions): McpResult[] { + const results: McpResult[] = []; + for (const id of opts.tools) { + const tool = TOOLS.find((t) => t.id === id); + if (!tool) continue; + const target = mcpTarget(tool, opts.scope, ctx); + if (!target) continue; + const base = target.format === "toml" ? registerToml(target) : registerJson(target); + results.push({ toolId: tool.id, label: tool.label, scope: opts.scope, ...base }); + } + return results; +} + +// Remove the docs MCP for a scope from every tool that could hold it. Mirrors +// uninstall, which reverses across all tools rather than a chosen set. +export function unregisterMcp(ctx: DoctorContext, scope: Scope): McpResult[] { + const results: McpResult[] = []; + for (const tool of TOOLS) { + const target = mcpTarget(tool, scope, ctx); + if (!target) continue; + const base = target.format === "toml" ? unregisterToml(target) : unregisterJson(target); + if (base.outcome === "skipped" && !base.detail) continue; + results.push({ toolId: tool.id, label: tool.label, scope, ...base }); + } + return results; +} diff --git a/src/tools.ts b/src/tools.ts index 8a2cb07..70d7927 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -23,6 +23,35 @@ interface SkillDir { segments: string[]; } +// A tool's MCP config file at one scope. `project` is relative to the working +// directory, `personal` to the home directory unless `fromConfig` is set, in +// which case it is relative to the tool's resolved config directory. +export interface McpFile { + scope: Scope; + fromConfig?: boolean; + // Base the personal path on the config-env override directory if it is set, + // else the home directory. Used by a file that sits beside the config dir + // rather than inside it, like Claude Code's ~/.claude.json which moves to + // $CLAUDE_CONFIG_DIR/.claude.json when that variable is set. + fromConfigEnv?: boolean; + segments: string[]; +} + +// How to register the gaffa docs MCP server in a tool. Each tool keeps the +// server list under `mcpServers` (JSON) or `[mcp_servers.NAME]` (TOML), but the +// entry shape differs: the field carrying the URL, whether a `type` is required, +// and any fixed extras. All verified against each tool's own docs (GAF-669). +export interface McpConfig { + format: "json" | "toml"; + files: McpFile[]; + // The field name that carries the server URL in an entry. + urlKey: "url" | "serverUrl"; + // A `type` the entry needs, if any (Claude Code and Copilot want "http"). + type?: string; + // Fixed extra fields on the entry (Copilot wants a tools allowlist). + extra?: Record; +} + export interface Tool { id: string; label: string; @@ -31,6 +60,8 @@ export interface Tool { // Config directory under the home directory, the marker that the tool is installed. configSegments: string[]; skillDirs: SkillDir[]; + // How to register the docs MCP server, if we register it for this tool. + mcp?: McpConfig; } // The gaffa skills we look for. A skill copy is a directory named `gaffa-*` that @@ -47,6 +78,17 @@ export const TOOLS: Tool[] = [ { scope: "project", segments: [".claude", "skills"] }, { scope: "personal", fromConfig: true, segments: ["skills"] }, ], + // user scope writes ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json when + // that is set), project scope writes .mcp.json. + mcp: { + format: "json", + files: [ + { scope: "personal", fromConfigEnv: true, segments: [".claude.json"] }, + { scope: "project", segments: [".mcp.json"] }, + ], + urlKey: "url", + type: "http", + }, }, { id: "codex", @@ -58,6 +100,16 @@ export const TOOLS: Tool[] = [ { scope: "project", segments: [".agents", "skills"] }, { scope: "personal", segments: [".agents", "skills"] }, ], + // TOML, not JSON. HTTP transport is supported on current Codex, older + // versions were stdio only, so an old install ignores this entry. + mcp: { + format: "toml", + files: [ + { scope: "personal", fromConfig: true, segments: ["config.toml"] }, + { scope: "project", segments: [".codex", "config.toml"] }, + ], + urlKey: "url", + }, }, { id: "copilot", @@ -70,6 +122,15 @@ export const TOOLS: Tool[] = [ { scope: "personal", fromConfig: true, segments: ["skills"] }, { scope: "personal", segments: [".agents", "skills"] }, ], + // Only the personal file (~/.copilot/mcp-config.json) is documented, so a + // project install writes no Copilot MCP entry. + mcp: { + format: "json", + files: [{ scope: "personal", fromConfig: true, segments: ["mcp-config.json"] }], + urlKey: "url", + type: "http", + extra: { tools: ["*"] }, + }, }, { id: "cursor", @@ -79,6 +140,14 @@ export const TOOLS: Tool[] = [ { scope: "project", segments: [".agents", "skills"] }, { scope: "project", segments: [".claude", "skills"] }, ], + mcp: { + format: "json", + files: [ + { scope: "personal", segments: [".cursor", "mcp.json"] }, + { scope: "project", segments: [".cursor", "mcp.json"] }, + ], + urlKey: "url", + }, }, { id: "antigravity", @@ -92,6 +161,15 @@ export const TOOLS: Tool[] = [ { scope: "project", segments: [".agent", "skills"] }, { scope: "personal", segments: [".gemini", "config", "skills"] }, ], + // Antigravity uses serverUrl for remote servers, not url. + mcp: { + format: "json", + files: [ + { scope: "personal", segments: [".gemini", "config", "mcp_config.json"] }, + { scope: "project", segments: [".agents", "mcp_config.json"] }, + ], + urlKey: "serverUrl", + }, }, ]; @@ -143,10 +221,13 @@ function gaffaSkillsIn(dir: string): string[] { .sort(); } -function resolveConfigPath(tool: Tool, ctx: DoctorContext): string { +function configEnvOverride(tool: Tool, ctx: DoctorContext): string | undefined { const override = tool.configEnv ? ctx.env[tool.configEnv] : undefined; - if (override && override.length > 0) return override; - return join(ctx.home, ...tool.configSegments); + return override && override.length > 0 ? override : undefined; +} + +function resolveConfigPath(tool: Tool, ctx: DoctorContext): string { + return configEnvOverride(tool, ctx) ?? join(ctx.home, ...tool.configSegments); } export interface SkillTarget { @@ -169,6 +250,37 @@ export function skillTargets(tool: Tool, ctx: DoctorContext): SkillTarget[] { }); } +export interface McpTarget { + path: string; + format: "json" | "toml"; + urlKey: "url" | "serverUrl"; + type?: string; + extra?: Record; +} + +// The MCP config file to write for a tool at a scope, or undefined if the tool +// has no known MCP location there. Resolves the path the same way skillTargets +// does: project under the working directory, personal under home unless the +// file follows the tool's config directory. +export function mcpTarget(tool: Tool, scope: Scope, ctx: DoctorContext): McpTarget | undefined { + const mcp = tool.mcp; + if (!mcp) return undefined; + const file = mcp.files.find((f) => f.scope === scope); + if (!file) return undefined; + let base: string; + if (file.scope === "project") base = ctx.cwd; + else if (file.fromConfig) base = resolveConfigPath(tool, ctx); + else if (file.fromConfigEnv) base = configEnvOverride(tool, ctx) ?? ctx.home; + else base = ctx.home; + return { + path: join(base, ...file.segments), + format: mcp.format, + urlKey: mcp.urlKey, + type: mcp.type, + extra: mcp.extra, + }; +} + // Inspect every target tool against the given home, working directory and // environment. Reads the filesystem, writes nothing. export function inspectTools(ctx: DoctorContext): ToolReport[] { diff --git a/test/mcp.test.js b/test/mcp.test.js new file mode 100644 index 0000000..c56df4c --- /dev/null +++ b/test/mcp.test.js @@ -0,0 +1,362 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { registerMcp, unregisterMcp, MCP_NAME, MCP_URL } from "../dist/mcp.js"; + +const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +function tmp() { + return mkdtempSync(join(tmpdir(), "gaffa-mcp-")); +} + +function cleanup(...dirs) { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +test("register writes the right entry shape per tool at personal scope", () => { + const home = tmp(), cwd = tmp(); + try { + const ctx = { home, cwd, env: {} }; + registerMcp(ctx, { tools: ["claude-code", "cursor", "antigravity", "copilot"], scope: "personal" }); + + // Claude Code: ~/.claude.json, type http + url. + assert.deepEqual(readJson(join(home, ".claude.json")).mcpServers[MCP_NAME], { + type: "http", + url: MCP_URL, + }); + // Cursor: url only, no type. + assert.deepEqual(readJson(join(home, ".cursor", "mcp.json")).mcpServers[MCP_NAME], { + url: MCP_URL, + }); + // Antigravity: serverUrl, not url. + assert.deepEqual(readJson(join(home, ".gemini", "config", "mcp_config.json")).mcpServers[MCP_NAME], { + serverUrl: MCP_URL, + }); + // Copilot: type http, url, tools allowlist. + assert.deepEqual(readJson(join(home, ".copilot", "mcp-config.json")).mcpServers[MCP_NAME], { + type: "http", + url: MCP_URL, + tools: ["*"], + }); + } finally { + cleanup(home, cwd); + } +}); + +test("register merges into an existing config without dropping the user's keys", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync( + file, + JSON.stringify({ mcpServers: { other: { command: "x" } }, someTopKey: 1 }, null, 2), + ); + + const results = registerMcp({ home, cwd, env: {} }, { tools: ["cursor"], scope: "personal" }); + assert.equal(results[0].outcome, "added"); + + const doc = readJson(file); + assert.equal(doc.someTopKey, 1); // untouched + assert.deepEqual(doc.mcpServers.other, { command: "x" }); // untouched + assert.deepEqual(doc.mcpServers[MCP_NAME], { url: MCP_URL }); // added + } finally { + cleanup(home, cwd); + } +}); + +test("a second register is a no-op reported as unchanged", () => { + const home = tmp(), cwd = tmp(); + try { + const ctx = { home, cwd, env: {} }; + registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + const again = registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + assert.equal(again[0].outcome, "unchanged"); + } finally { + cleanup(home, cwd); + } +}); + +test("register backs up and refuses a malformed JSON config", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(file, "{ not valid json "); + + const results = registerMcp({ home, cwd, env: {} }, { tools: ["cursor"], scope: "personal" }); + assert.equal(results[0].outcome, "refused"); + assert.equal(readFileSync(file, "utf8"), "{ not valid json "); // left as-is + assert.ok(existsSync(`${file}.gaffa.bak`)); // backed up + } finally { + cleanup(home, cwd); + } +}); + +test("register overwrites a gaffa-docs pointing elsewhere and notes the collision", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(file, JSON.stringify({ mcpServers: { [MCP_NAME]: { url: "https://old.example" } } })); + + const results = registerMcp({ home, cwd, env: {} }, { tools: ["cursor"], scope: "personal" }); + assert.equal(results[0].outcome, "updated"); + assert.match(results[0].detail, /replaced/); + assert.equal(readJson(file).mcpServers[MCP_NAME].url, MCP_URL); + } finally { + cleanup(home, cwd); + } +}); + +test("register adds a TOML table for Codex and preserves existing config", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync(file, 'model = "gpt-5"\n\n[mcp_servers.other]\ncommand = "x"\n'); + + const results = registerMcp({ home, cwd, env: {} }, { tools: ["codex"], scope: "personal" }); + assert.equal(results[0].outcome, "added"); + + const text = readFileSync(file, "utf8"); + assert.match(text, /model = "gpt-5"/); // preserved + assert.match(text, /\[mcp_servers\.other\]/); // preserved + assert.match(text, new RegExp(`\\[mcp_servers\\.${MCP_NAME}\\]`)); + assert.match(text, new RegExp(`url = "${MCP_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`)); + + // Idempotent. + const again = registerMcp({ home, cwd, env: {} }, { tools: ["codex"], scope: "personal" }); + assert.equal(again[0].outcome, "unchanged"); + } finally { + cleanup(home, cwd); + } +}); + +test("copilot has no project MCP file, so a project register skips it", () => { + const home = tmp(), cwd = tmp(); + try { + const results = registerMcp({ home, cwd, env: {} }, { tools: ["copilot"], scope: "project" }); + assert.equal(results.length, 0); + } finally { + cleanup(home, cwd); + } +}); + +test("unregister removes our entry and leaves the user's other servers", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: "x" } } })); + const ctx = { home, cwd, env: {} }; + registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + + const results = unregisterMcp(ctx, "personal").filter((r) => r.toolId === "cursor"); + assert.equal(results[0].outcome, "removed"); + const doc = readJson(file); + assert.equal(doc.mcpServers[MCP_NAME], undefined); // gone + assert.deepEqual(doc.mcpServers.other, { command: "x" }); // kept + } finally { + cleanup(home, cwd); + } +}); + +test("unregister leaves a gaffa-docs that points elsewhere", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + writeFileSync(file, JSON.stringify({ mcpServers: { [MCP_NAME]: { url: "https://mine.example" } } })); + + const results = unregisterMcp({ home, cwd, env: {} }, "personal").filter((r) => r.toolId === "cursor"); + assert.equal(results[0].outcome, "skipped"); + assert.equal(readJson(file).mcpServers[MCP_NAME].url, "https://mine.example"); // untouched + } finally { + cleanup(home, cwd); + } +}); + +test("register then unregister restores the Codex TOML file exactly", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + const original = 'model = "gpt-5"\n\n[mcp_servers.other]\ncommand = "x"\n'; + writeFileSync(file, original); + const ctx = { home, cwd, env: {} }; + + registerMcp(ctx, { tools: ["codex"], scope: "personal" }); + const results = unregisterMcp(ctx, "personal").filter((r) => r.toolId === "codex"); + assert.equal(results[0].outcome, "removed"); + // Surgical: for a normally terminated config, adding then removing our table + // leaves the rest exactly as it was. + assert.equal(readFileSync(file, "utf8"), original); + } finally { + cleanup(home, cwd); + } +}); + +test("claude-code personal MCP follows CLAUDE_CONFIG_DIR, else home", () => { + const home = tmp(), cwd = tmp(), cfg = tmp(), home2 = tmp(); + try { + registerMcp({ home, cwd, env: { CLAUDE_CONFIG_DIR: cfg } }, { tools: ["claude-code"], scope: "personal" }); + assert.ok(existsSync(join(cfg, ".claude.json"))); // the override dir + assert.ok(!existsSync(join(home, ".claude.json"))); // not home + + registerMcp({ home: home2, cwd, env: {} }, { tools: ["claude-code"], scope: "personal" }); + assert.ok(existsSync(join(home2, ".claude.json"))); // falls back to home + } finally { + cleanup(home, cwd, cfg, home2); + } +}); + +test("claude-code project MCP writes .mcp.json in the working dir", () => { + const home = tmp(), cwd = tmp(); + try { + registerMcp({ home, cwd, env: {} }, { tools: ["claude-code"], scope: "project" }); + assert.deepEqual(readJson(join(cwd, ".mcp.json")).mcpServers[MCP_NAME], { type: "http", url: MCP_URL }); + } finally { + cleanup(home, cwd); + } +}); + +test("a null gaffa-docs entry does not crash register or unregister", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".cursor", "mcp.json"); + mkdirSync(join(home, ".cursor"), { recursive: true }); + const ctx = { home, cwd, env: {} }; + + writeFileSync(file, JSON.stringify({ mcpServers: { [MCP_NAME]: null } })); + const r = registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + assert.equal(r[0].outcome, "updated"); // replaced the null with our entry + assert.deepEqual(readJson(file).mcpServers[MCP_NAME], { url: MCP_URL }); + + writeFileSync(file, JSON.stringify({ mcpServers: { [MCP_NAME]: null, other: { url: "x" } } })); + const u = unregisterMcp(ctx, "personal").filter((x) => x.toolId === "cursor"); + assert.equal(u[0].outcome, "skipped"); // not recognisably ours, left alone, no throw + } finally { + cleanup(home, cwd); + } +}); + +test("unregister deletes a config file it emptied", () => { + const home = tmp(), cwd = tmp(); + try { + const ctx = { home, cwd, env: {} }; + registerMcp(ctx, { tools: ["cursor"], scope: "personal" }); + const file = join(home, ".cursor", "mcp.json"); + assert.ok(existsSync(file)); + unregisterMcp(ctx, "personal"); + assert.ok(!existsSync(file)); + } finally { + cleanup(home, cwd); + } +}); + +test("TOML edits preserve a comment and section that follow our table", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + const original = `[mcp_servers.${MCP_NAME}]\nurl = "${MCP_URL}"\n\n# my own servers\n[mcp_servers.other]\ncommand = "x"\n`; + writeFileSync(file, original); + const ctx = { home, cwd, env: {} }; + + assert.equal(registerMcp(ctx, { tools: ["codex"], scope: "personal" })[0].outcome, "unchanged"); + unregisterMcp(ctx, "personal"); + const text = readFileSync(file, "utf8"); + assert.match(text, /# my own servers/); // comment kept + assert.match(text, /\[mcp_servers\.other\]/); // section kept + assert.doesNotMatch(text, new RegExp(`mcp_servers\\.${MCP_NAME}`)); // ours gone + } finally { + cleanup(home, cwd); + } +}); + +test("TOML register recognises the quoted-key spelling and does not duplicate", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync(file, `[mcp_servers."${MCP_NAME}"]\nurl = "https://old.example"\n`); + + registerMcp({ home, cwd, env: {} }, { tools: ["codex"], scope: "personal" }); + const text = readFileSync(file, "utf8"); + assert.equal((text.match(/mcp_servers/g) || []).length, 1); // one table, not a duplicate + assert.match(text, new RegExp(`url = "${MCP_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`)); + } finally { + cleanup(home, cwd); + } +}); + +test("TOML unregister removes our sub-table too", () => { + const home = tmp(), cwd = tmp(); + try { + const file = join(home, ".codex", "config.toml"); + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync( + file, + `[mcp_servers.${MCP_NAME}]\nurl = "${MCP_URL}"\n\n[mcp_servers.${MCP_NAME}.http_headers]\nX = "y"\n\n[mcp_servers.other]\ncommand = "x"\n`, + ); + const ctx = { home, cwd, env: {} }; + + assert.equal(unregisterMcp(ctx, "personal").filter((x) => x.toolId === "codex")[0].outcome, "removed"); + const text = readFileSync(file, "utf8"); + assert.doesNotMatch(text, new RegExp(`mcp_servers\\.${MCP_NAME}`)); // parent and sub-table gone + assert.match(text, /\[mcp_servers\.other\]/); // unrelated section kept + } finally { + cleanup(home, cwd); + } +}); + +// End to end through the built binary, project scope so files land in the temp cwd. +function run(args, cwd) { + try { + const stdout = execFileSync(process.execPath, [cli, ...args], { encoding: "utf8", cwd }); + return { stdout, code: 0 }; + } catch (err) { + return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", code: err.status }; + } +} + +function sourceSkill(srcDir, name) { + mkdirSync(join(srcDir, name), { recursive: true }); + writeFileSync(join(srcDir, name, "SKILL.md"), "skill\n"); +} + +test("cli install registers the docs MCP, and --no-mcp skips it", () => { + const cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const base = ["install", "--tools=cursor", "--scope=project", `--skills-dir=${src}`, "--yes"]; + + const withMcp = run(base, cwd); + assert.equal(withMcp.code, 0); + assert.match(withMcp.stdout, /Registered the gaffa-docs MCP server/); + assert.equal(readJson(join(cwd, ".cursor", "mcp.json")).mcpServers[MCP_NAME].url, MCP_URL); + + // A fresh cwd with --no-mcp writes no MCP config. + const cwd2 = tmp(); + try { + const noMcp = run([...base, "--no-mcp"], cwd2); + assert.equal(noMcp.code, 0); + assert.doesNotMatch(noMcp.stdout, /MCP server/); + assert.ok(!existsSync(join(cwd2, ".cursor", "mcp.json"))); + } finally { + cleanup(cwd2); + } + } finally { + cleanup(cwd, src); + } +});