Skip to content
Merged
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
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

```
Expand Down
13 changes: 11 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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 {
Expand All @@ -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`,
);
Expand Down
59 changes: 59 additions & 0 deletions src/doctor.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
178 changes: 178 additions & 0 deletions src/tools.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
}

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,
};
});
}
Loading
Loading