Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 52 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand Down Expand Up @@ -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<McpResult["outcome"], string> = {
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<number> {
const ctx = processContext();
const interactive = Boolean(process.stdin.isTTY) && !flags.bools.has("yes");
Expand Down Expand Up @@ -169,6 +212,9 @@ async function runInstall(flags: Flags): Promise<number> {
}

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

Expand All @@ -185,6 +231,9 @@ async function runUninstall(flags: Flags): Promise<number> {
}

process.stdout.write(formatUninstall(uninstall(ctx, scope), ctx));
if (!flags.bools.has("no-mcp")) {
process.stdout.write(formatMcpUnregister(unregisterMcp(ctx, scope), ctx));
}
return 0;
}

Expand Down
251 changes: 251 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const entry: Record<string, unknown> = {};
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<string, unknown> {
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<string, unknown>; servers: Record<string, unknown> } | "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<string, unknown>;
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<string, unknown> };
}

function backup(path: string, text: string): string {
const bak = `${path}.gaffa.bak`;
writeFileSync(bak, text);
return bak;
}

function registerJson(target: McpTarget): Omit<McpResult, "toolId" | "label" | "scope"> {
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<McpResult, "toolId" | "label" | "scope"> {
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<McpResult, "toolId" | "label" | "scope"> {
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<McpResult, "toolId" | "label" | "scope"> {
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;
}
Loading
Loading