From 004c91780039545220e4202620a8763bd4affcee Mon Sep 17 00:00:00 2001 From: Dominic Frei Date: Sat, 25 Jul 2026 12:02:39 +0800 Subject: [PATCH] Add the doctor command to detect installed tools gaffa doctor reports which of the target AI coding tools are on the machine, where each keeps its config, and whether the gaffa skills are already set up in it. It reads only and writes nothing, and adds no runtime dependency. Tools: Claude Code, Codex, GitHub Copilot, Cursor and Antigravity. Each is detected by its config directory rather than a binary on the path, since an IDE may put nothing on the path. Paths come from each tool's own docs, cross-checked against the plugin spike where a tool was installed for real. A config env override (CLAUDE_CONFIG_DIR, CODEX_HOME, COPILOT_HOME) moves both the detection and the skills lookup. Skills detection reports present or absent for now. Drift against a written receipt comes with the install command. Add --json for machine output, and unit plus integration tests. --- README.md | 21 ++++- src/cli.ts | 13 ++- src/doctor.ts | 59 +++++++++++++ src/tools.ts | 178 ++++++++++++++++++++++++++++++++++++++ test/doctor.test.js | 204 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 src/doctor.ts create mode 100644 src/tools.ts create mode 100644 test/doctor.test.js diff --git a/README.md b/README.md index 2aa63b8..6e4ae90 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # @gaffa-dev/cli -The Gaffa command line tool. This is the skeleton. It ships with `--version` and -`--help` and nothing else yet, and exists to prove the release path before there -is anything real to release. +The Gaffa command line tool for setting your AI coding tools up with the Gaffa +skills. ## Use it @@ -13,6 +12,22 @@ npx @gaffa-dev/cli --help npx @gaffa-dev/cli --version ``` +### doctor + +`doctor` reports which of the supported tools are on your machine, where each +keeps its config, and whether the gaffa skills are already set up in it. It reads +only and writes nothing. + +``` +npx @gaffa-dev/cli doctor +npx @gaffa-dev/cli doctor --json +``` + +Supported tools: Claude Code, Codex, GitHub Copilot, Cursor and Antigravity. +Each is detected by its config directory rather than a binary on the path, since +an IDE may put nothing on the path. `--json` prints the same result as structured +output for scripts. + ## Develop ``` diff --git a/src/cli.ts b/src/cli.ts index 25958f3..c4d96f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { readFileSync } from "node:fs"; +import { runDoctor, processContext } from "./doctor.js"; const pkg = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf8"), @@ -10,11 +11,13 @@ const HELP = `gaffa - the Gaffa command line tool Usage gaffa [command] [options] +Commands + doctor Report which AI coding tools are installed and whether the + gaffa skills are set up in them. Add --json for machine output. + Options -v, --version Print the version and exit -h, --help Show this help and exit - -More commands are on the way. Run "gaffa --help" any time to see what is here. `; function main(argv: string[]): number { @@ -30,6 +33,12 @@ function main(argv: string[]): number { return 0; } + if (args[0] === "doctor") { + const json = args.includes("--json"); + process.stdout.write(runDoctor(processContext(), json)); + return 0; + } + process.stderr.write( `Unknown command: ${args.join(" ")}\nRun "gaffa --help" for usage.\n`, ); diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..b6bac72 --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,59 @@ +// `gaffa doctor`: report which target tools are installed, where they keep their +// config, and whether the gaffa skills are already in place. Reads only, writes +// nothing. + +import { homedir } from "node:os"; +import { sep } from "node:path"; +import { inspectTools, type DoctorContext, type ToolReport } from "./tools.js"; + +// Replace a leading home directory with ~ for a shorter, readable path. Only +// when home is the whole path or a real path prefix, so /Users/dom does not turn +// /Users/dominic into ~inic. +function short(path: string, home: string): string { + if (home.length === 0) return path; + if (path === home) return "~"; + if (path.startsWith(home + sep)) return "~" + path.slice(home.length); + return path; +} + +function skillSummary(report: ToolReport, home: string): string[] { + const lines: string[] = []; + for (const loc of report.skillLocations) { + if (loc.skills.length === 0) continue; + lines.push(` skills ${loc.skills.join(", ")} (${loc.scope}: ${short(loc.path, home)})`); + } + return lines; +} + +export function formatHuman(reports: ToolReport[], home: string): string { + const blocks = reports.map((report) => { + const lines = [ + `${report.label} ${report.installed ? "installed" : "not found"}`, + ` config ${short(report.configPath, home)}${report.installed ? "" : " (not present)"}`, + ]; + const skills = skillSummary(report, home); + if (skills.length > 0) { + lines.push(...skills); + } else if (report.installed) { + lines.push(" skills none found"); + } + return lines.join("\n"); + }); + return blocks.join("\n\n") + "\n"; +} + +export function formatJson(reports: ToolReport[]): string { + return JSON.stringify({ tools: reports }, null, 2) + "\n"; +} + +// Build the doctor report as text. `json` selects the machine-readable form. +export function runDoctor(ctx: DoctorContext, json: boolean): string { + const reports = inspectTools(ctx); + return json ? formatJson(reports) : formatHuman(reports, ctx.home); +} + +// Context from the real process, used by the CLI. Kept separate so tests can +// drive runDoctor with a controlled home, working directory and environment. +export function processContext(): DoctorContext { + return { home: homedir(), cwd: process.cwd(), env: process.env }; +} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..20d1fdc --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,178 @@ +// The AI coding tools the CLI targets, and how to detect each one on disk. +// +// Detection reads config directories rather than a binary on PATH, since an IDE +// extension may put nothing on the path. Every path here is the tool's own +// documented location, cross-checked against the plugin spike (GAF-662) where a +// tool was installed for real. The exact Windows form for Codex and Antigravity +// is the logical expansion of their documented tilde paths (~ becomes the user +// profile), which those tools' own docs do not spell out per OS. + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export type Scope = "project" | "personal"; + +// A place a tool reads skills from. `project` is relative to the working +// directory. `personal` is relative to the user's home directory, unless +// `fromConfig` is set, in which case it is relative to the tool's resolved +// config directory so a config env override moves it too. +interface SkillDir { + scope: Scope; + fromConfig?: boolean; + // Path segments under the scope's base, e.g. [".claude", "skills"]. + segments: string[]; +} + +interface Tool { + id: string; + label: string; + // Environment variable that overrides the config directory, if the tool has one. + configEnv?: string; + // Config directory under the home directory, the marker that the tool is installed. + configSegments: string[]; + skillDirs: SkillDir[]; +} + +// The gaffa skills we look for. A skill copy is a directory named `gaffa-*` that +// holds a SKILL.md. +const GAFFA_PREFIX = "gaffa-"; + +export const TOOLS: Tool[] = [ + { + id: "claude-code", + label: "Claude Code", + configEnv: "CLAUDE_CONFIG_DIR", + configSegments: [".claude"], + skillDirs: [ + { scope: "project", segments: [".claude", "skills"] }, + { scope: "personal", fromConfig: true, segments: ["skills"] }, + ], + }, + { + id: "codex", + label: "Codex", + configEnv: "CODEX_HOME", + configSegments: [".codex"], + // Codex reads .agents/skills and does not read .claude/skills. + skillDirs: [ + { scope: "project", segments: [".agents", "skills"] }, + { scope: "personal", segments: [".agents", "skills"] }, + ], + }, + { + id: "copilot", + label: "GitHub Copilot", + configEnv: "COPILOT_HOME", + configSegments: [".copilot"], + skillDirs: [ + { scope: "project", segments: [".agents", "skills"] }, + { scope: "project", segments: [".claude", "skills"] }, + { scope: "personal", fromConfig: true, segments: ["skills"] }, + { scope: "personal", segments: [".agents", "skills"] }, + ], + }, + { + id: "cursor", + label: "Cursor", + configSegments: [".cursor"], + skillDirs: [ + { scope: "project", segments: [".agents", "skills"] }, + { scope: "project", segments: [".claude", "skills"] }, + ], + }, + { + id: "antigravity", + label: "Antigravity", + // ~/.gemini/antigravity-cli is Antigravity's own directory. Plain ~/.gemini + // also belongs to the Gemini CLI, so it would be a false positive. + configSegments: [".gemini", "antigravity-cli"], + skillDirs: [ + { scope: "project", segments: [".agents", "skills"] }, + // .agent/skills is the legacy spelling Antigravity still reads. + { scope: "project", segments: [".agent", "skills"] }, + { scope: "personal", segments: [".gemini", "config", "skills"] }, + ], + }, +]; + +export interface DoctorContext { + home: string; + cwd: string; + env: Record; +} + +export interface SkillLocation { + scope: Scope; + path: string; + exists: boolean; + skills: string[]; +} + +export interface ToolReport { + id: string; + label: string; + installed: boolean; + configPath: string; + skillLocations: SkillLocation[]; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +// The gaffa skill folders directly under a skills directory, sorted. +function gaffaSkillsIn(dir: string): string[] { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter( + (e) => + e.isDirectory() && + e.name.startsWith(GAFFA_PREFIX) && + existsSync(join(dir, e.name, "SKILL.md")), + ) + .map((e) => e.name) + .sort(); +} + +function resolveConfigPath(tool: Tool, ctx: DoctorContext): string { + const override = tool.configEnv ? ctx.env[tool.configEnv] : undefined; + if (override && override.length > 0) return override; + return join(ctx.home, ...tool.configSegments); +} + +// Inspect every target tool against the given home, working directory and +// environment. Reads the filesystem, writes nothing. +export function inspectTools(ctx: DoctorContext): ToolReport[] { + return TOOLS.map((tool) => { + const configPath = resolveConfigPath(tool, ctx); + const skillLocations = tool.skillDirs.map((dir) => { + let base: string; + if (dir.scope === "project") base = ctx.cwd; + else if (dir.fromConfig) base = configPath; + else base = ctx.home; + const path = join(base, ...dir.segments); + return { + scope: dir.scope, + path, + exists: isDirectory(path), + skills: gaffaSkillsIn(path), + }; + }); + return { + id: tool.id, + label: tool.label, + installed: isDirectory(configPath), + configPath, + skillLocations, + }; + }); +} diff --git a/test/doctor.test.js b/test/doctor.test.js new file mode 100644 index 0000000..32672eb --- /dev/null +++ b/test/doctor.test.js @@ -0,0 +1,204 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { inspectTools } from "../dist/tools.js"; +import { runDoctor } from "../dist/doctor.js"; + +const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +function tmp() { + return mkdtempSync(join(tmpdir(), "gaffa-doctor-")); +} + +// Create a skill folder (a directory with a SKILL.md) under a skills directory. +function skill(skillsDir, name) { + mkdirSync(join(skillsDir, name), { recursive: true }); + writeFileSync(join(skillsDir, name, "SKILL.md"), "name\n"); +} + +function report(reports, id) { + const r = reports.find((x) => x.id === id); + assert.ok(r, `no report for ${id}`); + return r; +} + +test("nothing installed: every tool reports not installed and no skills", () => { + const home = tmp(); + const cwd = tmp(); + try { + const reports = inspectTools({ home, cwd, env: {} }); + assert.equal(reports.length, 5); + for (const r of reports) { + assert.equal(r.installed, false); + for (const loc of r.skillLocations) assert.deepEqual(loc.skills, []); + } + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("config directory presence marks a tool installed", () => { + const home = tmp(); + const cwd = tmp(); + try { + mkdirSync(join(home, ".codex"), { recursive: true }); + const reports = inspectTools({ home, cwd, env: {} }); + assert.equal(report(reports, "codex").installed, true); + assert.equal(report(reports, "claude-code").installed, false); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("a config env override is used and reported as the config path", () => { + const home = tmp(); + const cwd = tmp(); + const override = tmp(); + try { + const reports = inspectTools({ home, cwd, env: { CLAUDE_CONFIG_DIR: override } }); + const claude = report(reports, "claude-code"); + assert.equal(claude.installed, true); + assert.equal(claude.configPath, override); + } finally { + for (const d of [home, cwd, override]) rmSync(d, { recursive: true, force: true }); + } +}); + +test("a config env override also moves the personal skills lookup", () => { + const home = tmp(); + const cwd = tmp(); + const override = tmp(); + try { + skill(join(override, "skills"), "gaffa-find"); + const claude = report( + inspectTools({ home, cwd, env: { CLAUDE_CONFIG_DIR: override } }), + "claude-code", + ); + const personal = claude.skillLocations.find((l) => l.scope === "personal"); + assert.equal(personal.path, join(override, "skills")); + assert.deepEqual(personal.skills, ["gaffa-find"]); + } finally { + for (const d of [home, cwd, override]) rmSync(d, { recursive: true, force: true }); + } +}); + +test("plain ~/.gemini does not mark Antigravity installed, ~/.gemini/antigravity-cli does", () => { + const home = tmp(); + const cwd = tmp(); + try { + mkdirSync(join(home, ".gemini"), { recursive: true }); + assert.equal(report(inspectTools({ home, cwd, env: {} }), "antigravity").installed, false); + mkdirSync(join(home, ".gemini", "antigravity-cli"), { recursive: true }); + assert.equal(report(inspectTools({ home, cwd, env: {} }), "antigravity").installed, true); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("gaffa skills are detected in a project skills directory, shared paths surface per tool", () => { + const home = tmp(); + const cwd = tmp(); + try { + skill(join(cwd, ".claude", "skills"), "gaffa-find"); + const reports = inspectTools({ home, cwd, env: {} }); + const claudeLoc = report(reports, "claude-code").skillLocations.find( + (l) => l.scope === "project" && l.path.includes(".claude"), + ); + assert.deepEqual(claudeLoc.skills, ["gaffa-find"]); + // Copilot also reads project .claude/skills, so it surfaces there too. + const copilotLoc = report(reports, "copilot").skillLocations.find( + (l) => l.scope === "project" && l.path.includes(".claude"), + ); + assert.deepEqual(copilotLoc.skills, ["gaffa-find"]); + // Codex does not read .claude/skills, so it stays empty. + for (const loc of report(reports, "codex").skillLocations) { + assert.deepEqual(loc.skills, []); + } + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("only gaffa-* directories with a SKILL.md count as skills", () => { + const home = tmp(); + const cwd = tmp(); + try { + const skillsDir = join(cwd, ".agents", "skills"); + mkdirSync(join(skillsDir, "gaffa-nope"), { recursive: true }); // no SKILL.md + skill(skillsDir, "other-skill"); // has SKILL.md but not a gaffa skill + skill(skillsDir, "gaffa-bulk"); // counts + const loc = report(inspectTools({ home, cwd, env: {} }), "codex").skillLocations.find( + (l) => l.scope === "project", + ); + assert.deepEqual(loc.skills, ["gaffa-bulk"]); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("json output has a tools array with an entry per tool", () => { + const home = tmp(); + const cwd = tmp(); + try { + const parsed = JSON.parse(runDoctor({ home, cwd, env: {} }, true)); + assert.equal(parsed.tools.length, 5); + assert.ok(parsed.tools.every((t) => "installed" in t && "configPath" in t)); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("human output names every tool", () => { + const home = tmp(); + const cwd = tmp(); + try { + const out = runDoctor({ home, cwd, env: {} }, false); + for (const label of ["Claude Code", "Codex", "GitHub Copilot", "Cursor", "Antigravity"]) { + assert.match(out, new RegExp(label)); + } + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +// The command runs against the real environment here, so assert only what holds +// regardless of what is installed on the machine. +function run(args) { + try { + const stdout = execFileSync(process.execPath, [cli, ...args], { encoding: "utf8" }); + return { stdout, code: 0 }; + } catch (err) { + return { stdout: err.stdout ?? "", code: err.status }; + } +} + +test("doctor runs and lists the tools", () => { + const { stdout, code } = run(["doctor"]); + assert.equal(code, 0); + assert.match(stdout, /Claude Code/); + assert.match(stdout, /Antigravity/); +}); + +test("doctor --json emits parseable json with five tools", () => { + const { stdout, code } = run(["doctor", "--json"]); + assert.equal(code, 0); + assert.equal(JSON.parse(stdout).tools.length, 5); +}); + +test("help lists the doctor command", () => { + const { stdout, code } = run(["--help"]); + assert.equal(code, 0); + assert.match(stdout, /doctor/); +});