diff --git a/src/cli.ts b/src/cli.ts index 8d67ba9..73a61b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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( @@ -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. @@ -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 { @@ -128,16 +127,11 @@ async function runInstall(flags: Flags): Promise { 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; @@ -210,7 +204,7 @@ async function main(argv: string[]): Promise { 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); diff --git a/src/doctor.ts b/src/doctor.ts index b6bac72..f215e1c 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -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 @@ -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, +): Promise { + 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 = fetchLatestVersion, +): Promise { 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 diff --git a/src/skills-source.ts b/src/skills-source.ts index ee09c93..1317961 100644 --- a/src/skills-source.ts +++ b/src/skills-source.ts @@ -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 @@ -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 }); @@ -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 { @@ -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 { + 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 { + 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 { + 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")) }; +} diff --git a/test/doctor.test.js b/test/doctor.test.js index 742ef22..413a8b9 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -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)); @@ -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 { @@ -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)); } @@ -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) { diff --git a/test/install.test.js b/test/install.test.js index f14fd23..eaee32e 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -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); } diff --git a/test/skills-source.test.js b/test/skills-source.test.js new file mode 100644 index 0000000..32bd831 --- /dev/null +++ b/test/skills-source.test.js @@ -0,0 +1,82 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { fetchLatestVersion, fetchNpmSource } from "../dist/skills-source.js"; + +function tmp() { + return mkdtempSync(join(tmpdir(), "gaffa-source-")); +} + +// A gzipped tarball shaped like an npm one: a package/ root with the skills +// inside, built with the same tar the code unpacks with. +function makeTarball(dir) { + mkdirSync(join(dir, "package", "skills", "gaffa-find"), { recursive: true }); + writeFileSync(join(dir, "package", "skills", "gaffa-find", "SKILL.md"), "find\n"); + execFileSync("tar", ["-czf", join(dir, "skills.tgz"), "-C", dir, "package"]); + return readFileSync(join(dir, "skills.tgz")); +} + +function jsonResponse(body) { + return { ok: true, status: 200, json: async () => body }; +} + +function bytesResponse(buffer) { + return { ok: true, status: 200, arrayBuffer: async () => buffer }; +} + +const metadata = { + version: "0.1.0", + dist: { tarball: "https://registry.npmjs.org/@gaffa-dev/skills/-/skills-0.1.0.tgz" }, +}; + +test("fetchNpmSource resolves latest, unpacks the tarball and reads the skills", async () => { + const dir = tmp(); + try { + const tarball = makeTarball(dir); + const source = await fetchNpmSource(async (url) => + String(url).endsWith("/latest") ? jsonResponse(metadata) : bytesResponse(tarball), + ); + assert.equal(source.version, "0.1.0"); + assert.deepEqual( + source.skills.map((s) => s.name), + ["gaffa-find"], + ); + assert.equal(readFileSync(join(source.skills[0].dir, "SKILL.md"), "utf8"), "find\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("fetchNpmSource fails clearly when the registry is unreachable", async () => { + await assert.rejects( + fetchNpmSource(async () => { + throw new Error("network down"); + }), + /Could not reach the npm registry/, + ); +}); + +test("fetchNpmSource fails clearly on a registry error status", async () => { + await assert.rejects( + fetchNpmSource(async () => ({ ok: false, status: 404 })), + /returned 404/, + ); +}); + +test("fetchNpmSource fails clearly when the tarball download breaks", async () => { + await assert.rejects( + fetchNpmSource(async (url) => { + if (String(url).endsWith("/latest")) return jsonResponse(metadata); + throw new Error("network down"); + }), + /Could not download/, + ); +}); + +test("fetchLatestVersion returns the published version", async () => { + assert.equal(await fetchLatestVersion(async () => jsonResponse(metadata)), "0.1.0"); +});