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/); +});