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
22 changes: 8 additions & 14 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sep } from "node:path";
import { createInterface } from "node:readline/promises";
import { runDoctor, processContext } from "./doctor.js";
import { inspectTools, TOOLS, type DoctorContext, type Scope } from "./tools.js";
import { readLocalSource } from "./skills-source.js";
import { fetchNpmSource, readLocalSource } from "./skills-source.js";
import { install, uninstall, type InstallResult, type UninstallResult } from "./install.js";

const pkg = JSON.parse(
Expand All @@ -21,11 +21,12 @@ Usage
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 Copy the gaffa skills into the tools you pick.
install Fetch the latest gaffa skills from npm and copy them into the
tools you pick.
--tools=a,b tool ids, default the installed ones
--scope=project or personal, default project
--skills-dir=PATH where to read the skills from,
or set GAFFA_SKILLS_DIR
--skills-dir=PATH read the skills from a local checkout
instead of npm, or set GAFFA_SKILLS_DIR
-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.
Expand All @@ -37,8 +38,6 @@ Options
-h, --help Show this help and exit

Tool ids: ${IDS}.
Until the skills are on npm, point install at a local checkout with --skills-dir
or GAFFA_SKILLS_DIR.
`;

interface Flags {
Expand Down Expand Up @@ -128,16 +127,11 @@ async function runInstall(flags: Flags): Promise<number> {
const ctx = processContext();
const interactive = Boolean(process.stdin.isTTY) && !flags.bools.has("yes");

// The skills come from npm unless a local checkout is pointed at explicitly.
const skillsDir = flags.values["skills-dir"] ?? ctx.env["GAFFA_SKILLS_DIR"];
if (!skillsDir) {
process.stderr.write(
"No skills source. Pass --skills-dir or set GAFFA_SKILLS_DIR.\nFetching them from npm arrives with GAF-705.\n",
);
return 1;
}
let source;
try {
source = readLocalSource(skillsDir);
source = skillsDir ? readLocalSource(skillsDir) : await fetchNpmSource();
} catch (err) {
process.stderr.write(`${(err as Error).message}\n`);
return 1;
Expand Down Expand Up @@ -210,7 +204,7 @@ async function main(argv: string[]): Promise<number> {
const flags = parseFlags(rest);

if (command === "doctor") {
process.stdout.write(runDoctor(processContext(), rest.includes("--json")));
process.stdout.write(await runDoctor(processContext(), rest.includes("--json")));
return 0;
}
if (command === "install") return runInstall(flags);
Expand Down
51 changes: 48 additions & 3 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import { homedir } from "node:os";
import { sep } from "node:path";
import { inspectTools, type DoctorContext, type ToolReport } from "./tools.js";
import { readReceipt } from "./receipt.js";
import { fetchLatestVersion } from "./skills-source.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
Expand Down Expand Up @@ -46,10 +48,53 @@ 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 {
// True when latest is a higher version than installed, by numeric x.y.z parts.
// Good enough for our published versions plus the 0.0.0-local placeholder, and
// a version it cannot read never triggers the line.
function isNewer(latest: string, installed: string): boolean {
const parts = (v: string) => v.split("-")[0].split(".").map(Number);
const [a, b] = [parts(latest), parts(installed)];
if (a.some(Number.isNaN) || b.some(Number.isNaN)) return false;
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
}
return false;
}

// The version check for loose-file installs: compare the oldest installed
// receipt against the latest published skills, and say when a newer one exists.
// Prints at most one line, writes nothing, and an unreachable registry is
// reported rather than failing the doctor run.
async function versionCheck(
reports: ToolReport[],
fetchLatest: () => Promise<string>,
): Promise<string> {
const dirs = new Set(reports.flatMap((r) => r.skillLocations.map((l) => l.path)));
const installed = [...dirs]
.map((dir) => readReceipt(dir)?.version)
.filter((v): v is string => Boolean(v));
if (installed.length === 0) return "";
const oldest = installed.reduce((min, v) => (isNewer(min, v) ? v : min));
let latest;
try {
latest = await fetchLatest();
} catch {
return "\nCould not check npm for a newer skills version.\n";
}
if (!isNewer(latest, oldest)) return "";
return `\nA newer skills version is published: ${latest} (installed ${oldest}). Run gaffa install to refresh.\n`;
}

// Build the doctor report as text. `json` selects the machine-readable form,
// which skips the version check to stay offline and stable.
export async function runDoctor(
ctx: DoctorContext,
json: boolean,
fetchLatest: () => Promise<string> = fetchLatestVersion,
): Promise<string> {
const reports = inspectTools(ctx);
return json ? formatJson(reports) : formatHuman(reports, ctx.home);
if (json) return formatJson(reports);
return formatHuman(reports, ctx.home) + (await versionCheck(reports, fetchLatest));
}

// Context from the real process, used by the CLI. Kept separate so tests can
Expand Down
91 changes: 79 additions & 12 deletions src/skills-source.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
// Where the skills come from. Today they are read from a local gaffa-for-ai
// checkout, pointed at with --skills-dir or GAFFA_SKILLS_DIR. When the skills
// ship as a versioned npm package (GAF-705) npm resolution slots in here and the
// rest of install does not change. If resolving the source fails it throws, so
// install stops before it touches anything on disk.
// Where the skills come from. By default they are fetched from npm as the
// latest published @gaffa-dev/skills, so a skill change reaches users without a
// CLI release. A local gaffa-for-ai checkout can be used instead, pointed at
// with --skills-dir or GAFFA_SKILLS_DIR. If resolving the source fails it
// throws, so install stops before it touches anything on disk.

import { existsSync, readFileSync, readdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const GAFFA_PREFIX = "gaffa-";

export const SKILLS_PACKAGE = "@gaffa-dev/skills";
const LATEST_URL = `https://registry.npmjs.org/${SKILLS_PACKAGE}/latest`;
const FETCH_TIMEOUT_MS = 10_000;

export interface SourceSkill {
name: string; // the gaffa-* directory name
dir: string; // absolute path to the skill directory in the source
Expand All @@ -19,9 +25,9 @@ export interface SkillSource {
skills: SourceSkill[];
}

// Read the gaffa-* skill directories (each holding a SKILL.md) from a local
// source directory. Throws if the directory is missing or holds no skills.
export function readLocalSource(skillsDir: string): SkillSource {
// The gaffa-* skill directories (each holding a SKILL.md) directly under a
// directory. Throws if there are none, naming the directory.
function readSkills(skillsDir: string): SourceSkill[] {
let entries;
try {
entries = readdirSync(skillsDir, { withFileTypes: true });
Expand All @@ -38,11 +44,17 @@ export function readLocalSource(skillsDir: string): SkillSource {
.map((e) => ({ name: e.name, dir: join(skillsDir, e.name) }))
.sort((a, b) => a.name.localeCompare(b.name));
if (skills.length === 0) throw new Error(`no gaffa skills found in ${skillsDir}`);
return { version: readSourceVersion(skillsDir), skills };
return skills;
}

// Read the skills from a local source directory. Throws if the directory is
// missing or holds no skills.
export function readLocalSource(skillsDir: string): SkillSource {
return { version: readSourceVersion(skillsDir), skills: readSkills(skillsDir) };
}

// The source's version, from its package.json if the checkout has one, else a
// local placeholder until GAF-705 gives the skills a published version.
// The local source's version, from its package.json if the checkout has one,
// else a placeholder that marks the copy as local.
function readSourceVersion(skillsDir: string): string {
try {
const pkg = JSON.parse(readFileSync(join(skillsDir, "package.json"), "utf8")) as {
Expand All @@ -54,3 +66,58 @@ function readSourceVersion(skillsDir: string): string {
}
return "0.0.0-local";
}

// What the registry reports for the latest published version.
interface LatestMetadata {
version: string;
tarballUrl: string;
}

async function fetchLatestMetadata(fetchImpl: typeof fetch): Promise<LatestMetadata> {
let response;
try {
response = await fetchImpl(LATEST_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
} catch {
throw new Error(`Could not reach the npm registry for ${SKILLS_PACKAGE}.`);
}
if (!response.ok) {
throw new Error(`The npm registry returned ${response.status} for ${SKILLS_PACKAGE}.`);
}
const doc = (await response.json()) as { version?: string; dist?: { tarball?: string } };
if (!doc.version || !doc.dist?.tarball) {
throw new Error(`Unexpected registry answer for ${SKILLS_PACKAGE}.`);
}
return { version: doc.version, tarballUrl: doc.dist.tarball };
}

// The latest published skills version, for the doctor version check. Throws if
// the registry cannot be reached, the caller decides how loud to be.
export async function fetchLatestVersion(fetchImpl: typeof fetch = fetch): Promise<string> {
return (await fetchLatestMetadata(fetchImpl)).version;
}

// Fetch the latest published skills from npm: resolve the version, download the
// tarball and unpack it into a temp directory. Throws with a clear message on
// any failure, so install writes nothing.
export async function fetchNpmSource(fetchImpl: typeof fetch = fetch): Promise<SkillSource> {
const meta = await fetchLatestMetadata(fetchImpl);
let response;
try {
response = await fetchImpl(meta.tarballUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
} catch {
throw new Error(`Could not download ${SKILLS_PACKAGE} ${meta.version} from npm.`);
}
if (!response.ok) {
throw new Error(`Downloading ${SKILLS_PACKAGE} ${meta.version} returned ${response.status}.`);
}
const dir = mkdtempSync(join(tmpdir(), "gaffa-skills-"));
const tarball = join(dir, "skills.tgz");
writeFileSync(tarball, Buffer.from(await response.arrayBuffer()));
const tar = spawnSync("tar", ["-xzf", tarball, "-C", dir]);
if (tar.status !== 0) {
const detail = tar.error ? tar.error.message : tar.stderr.toString().trim();
throw new Error(`Could not unpack the skills tarball: ${detail}`);
}
// npm tarballs unpack to package/, the skills sit in its skills directory.
return { version: meta.version, skills: readSkills(join(dir, "package", "skills")) };
}
91 changes: 87 additions & 4 deletions test/doctor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";

import { inspectTools } from "../dist/tools.js";
import { runDoctor } from "../dist/doctor.js";
import { writeReceipt } from "../dist/receipt.js";

const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url));

Expand Down Expand Up @@ -146,11 +147,11 @@ test("only gaffa-* directories with a SKILL.md count as skills", () => {
}
});

test("json output has a tools array with an entry per tool", () => {
test("json output has a tools array with an entry per tool", async () => {
const home = tmp();
const cwd = tmp();
try {
const parsed = JSON.parse(runDoctor({ home, cwd, env: {} }, true));
const parsed = JSON.parse(await runDoctor({ home, cwd, env: {} }, true));
assert.equal(parsed.tools.length, 5);
assert.ok(parsed.tools.every((t) => "installed" in t && "configPath" in t));
} finally {
Expand All @@ -159,11 +160,11 @@ test("json output has a tools array with an entry per tool", () => {
}
});

test("human output names every tool", () => {
test("human output names every tool", async () => {
const home = tmp();
const cwd = tmp();
try {
const out = runDoctor({ home, cwd, env: {} }, false);
const out = await runDoctor({ home, cwd, env: {} }, false);
for (const label of ["Claude Code", "Codex", "GitHub Copilot", "Cursor", "Antigravity"]) {
assert.match(out, new RegExp(label));
}
Expand All @@ -173,6 +174,88 @@ test("human output names every tool", () => {
}
});

// A receipt in cwd/.agents/skills, the loose-file install the version check reads.
function receiptAt(cwd, version) {
const dir = join(cwd, ".agents", "skills");
mkdirSync(dir, { recursive: true });
writeReceipt(dir, { version, scope: "project", files: [] });
}

test("doctor reports a newer published skills version", async () => {
const home = tmp();
const cwd = tmp();
try {
receiptAt(cwd, "0.1.0");
const out = await runDoctor({ home, cwd, env: {} }, false, async () => "0.2.0");
assert.match(out, /newer skills version is published: 0\.2\.0 \(installed 0\.1\.0\)/);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
}
});

test("doctor compares against the oldest receipt by version, not by string order", async () => {
const home = tmp();
const cwd = tmp();
try {
receiptAt(cwd, "0.10.0");
const claude = join(cwd, ".claude", "skills");
mkdirSync(claude, { recursive: true });
writeReceipt(claude, { version: "0.2.0", scope: "project", files: [] });
const out = await runDoctor({ home, cwd, env: {} }, false, async () => "0.10.0");
assert.match(out, /newer skills version is published: 0\.10\.0 \(installed 0\.2\.0\)/);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
}
});

test("doctor stays quiet when the installed skills are current", async () => {
const home = tmp();
const cwd = tmp();
try {
receiptAt(cwd, "0.2.0");
const out = await runDoctor({ home, cwd, env: {} }, false, async () => "0.2.0");
assert.doesNotMatch(out, /newer skills version/);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
}
});

test("doctor says so and carries on when the registry is unreachable", async () => {
const home = tmp();
const cwd = tmp();
try {
receiptAt(cwd, "0.1.0");
const out = await runDoctor({ home, cwd, env: {} }, false, async () => {
throw new Error("offline");
});
assert.match(out, /Could not check npm/);
assert.match(out, /Codex/); // the report itself still renders
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
}
});

test("doctor skips the version check without a receipt", async () => {
const home = tmp();
const cwd = tmp();
try {
let called = false;
const out = await runDoctor({ home, cwd, env: {} }, false, async () => {
called = true;
return "9.9.9";
});
assert.equal(called, false);
assert.doesNotMatch(out, /newer skills version/);
} 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) {
Expand Down
10 changes: 7 additions & 3 deletions test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,16 @@ test("cli install writes skills for a project scope in the working directory", (
}
});

test("cli install with no skills source errors and writes nothing", () => {
test("cli install with a missing skills dir errors and writes nothing", () => {
const cwd = tmp();
try {
const { code, stderr } = run(["install", "--tools=codex", "--scope=project", "--yes"], cwd);
const { code, stderr } = run(
["install", "--tools=codex", "--scope=project", `--skills-dir=${join(cwd, "nope")}`, "--yes"],
cwd,
);
assert.equal(code, 1);
assert.match(stderr, /GAFFA_SKILLS_DIR|skills source/);
assert.match(stderr, /skills source not found/);
assert.ok(!existsSync(join(cwd, ".agents")));
} finally {
cleanup(cwd);
}
Expand Down
Loading
Loading