From 9ac6225ec39eb3d48ca0a91d9352bbbaa3c253bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 10:59:53 +0000 Subject: [PATCH 1/2] feat: distribute the agent skills to Codex and Gemini CLI Publish the shared skills tree to two more harnesses. A Codex-native plugin manifest and marketplace catalog let Codex users install the plugin from this repo, and a root Gemini CLI extension manifest serves the skills folder that Gemini auto-discovers. A new skillsCheck gate target validates the skills tree against the Agent Skills spec, since Codex and Gemini load those folders directly and a frontmatter name that drifts from its directory ships a skill that silently fails to load. The version-bump check now names every version-carrying manifest, and the manifest tests enforce that all of them agree. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019As9A3ugixLQiAvcat1ZKk --- .agents/plugins/marketplace.json | 20 ++++ AGENTS.md | 29 ++++-- README.md | 30 +++++- build/plugin_version_check.ts | 29 +++++- build/skill_check.ts | 132 +++++++++++++++++++++++++ cspell.json | 1 + gemini-extension.json | 5 + plugins/zuke/.codex-plugin/plugin.json | 15 +++ tests/integration/skill_check_test.ts | 68 +++++++++++++ tests/plugin_manifest_test.ts | 127 +++++++++++++++++++----- tests/plugin_version_check_test.ts | 11 ++- tests/skill_check_test.ts | 113 +++++++++++++++++++++ zuke.ts | 20 ++++ 13 files changed, 556 insertions(+), 44 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 build/skill_check.ts create mode 100644 gemini-extension.json create mode 100644 plugins/zuke/.codex-plugin/plugin.json create mode 100644 tests/integration/skill_check_test.ts create mode 100644 tests/skill_check_test.ts diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..266eb4f3 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "zuke", + "interface": { + "displayName": "Zuke" + }, + "plugins": [ + { + "name": "zuke", + "source": { + "source": "local", + "path": "./plugins/zuke" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 6d163820..9f38fc06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -308,7 +308,8 @@ can drift from it. `zuke.ts`'s `ci` target depends on: `format` (`deno fmt --check`), `lint` (`deno lint`), `spell` (cspell), `coverage` (type-check, then the test suite with the 95% coverage gate), `coverageUpload` (skips locally without a `CODECOV_TOKEN`), `apiDocsCheck`, `docLint`, -`snippetsCheck`, `hclSyncCheck`, `pluginSyncCheck`, `pluginVersionCheck`, +`snippetsCheck`, `hclSyncCheck`, `pluginSyncCheck`, `skillsCheck`, +`pluginVersionCheck`, `prBodyLint`, `actionPinCheck`, `security`, and `lockCheck`. Read `zuke.ts`'s `ci` target for the current, authoritative list — this is a snapshot, not a second source of truth. @@ -342,7 +343,9 @@ build/ # reusable helpers behind zuke.ts's targets (docs, pub zuke, zuke.ps1 # bootstrap launchers (install Deno, run the build); zuke.json names the build class docs/ # long-form guides (linked from the README) skills/ # agent skills: zuke-write-build, zuke-setup -plugins/zuke/ # Claude Code plugin wrapping the skills +plugins/zuke/ # Claude Code + Codex plugin wrapping the skills +gemini-extension.json # Gemini CLI extension manifest (serves skills/) +.agents/plugins/ # Codex-native marketplace catalog .github/workflows/ci.yml # PR checks (ci gate, coreFloorCheck, test matrix) .github/workflows/integration.yml # e2e suite on the OS matrix (generated) .github/workflows/ai-review.yml # @zuke/ai PR review @@ -412,8 +415,15 @@ plugins/zuke/ # Claude Code plugin wrapping the skills - **Update docs with code.** If behaviour changes, update `README.md`, JSDoc, and the spec/acceptance criteria in the same PR. - **The agent skills are docs too — and they ship to a marketplace.** `skills/` - is the source of truth for `zuke-write-build` and `zuke-setup`, and - `plugins/zuke/` is the Claude Code plugin that publishes them. Any change to + is the source of truth for `zuke-write-build` and `zuke-setup`, published to + three harnesses: `plugins/zuke/` is the Claude Code plugin (whose manifests + Codex also reads, alongside the Codex-native + `plugins/zuke/.codex-plugin/plugin.json` and + `.agents/plugins/marketplace.json`), and the root `gemini-extension.json` + makes the repo a Gemini CLI extension that auto-discovers `skills/`. The + `skillsCheck` gate target validates `skills/` against the Agent Skills spec + (frontmatter `name` must match the folder), since Codex and Gemini load + those folders directly. Any change to the authoring surface or to a documented guarantee — a new `target()` method, a new `Build` override, changed CLI or authorization semantics — must be reflected in `skills/zuke-write-build/SKILL.md` and @@ -422,9 +432,12 @@ plugins/zuke/ # Claude Code plugin wrapping the skills a new package belongs in its catalogue table as well. Then, in order: 1. Run `./zuke pluginSync` to regenerate `plugins/zuke/skills/`. Never hand-edit the copies — `pluginSyncCheck` fails on drift. - 2. **Bump the plugin version by hand, in both manifests**: - `plugins/zuke/.claude-plugin/plugin.json` and the entry in - `.claude-plugin/marketplace.json`. Clients use the version to decide + 2. **Bump the plugin version by hand, in all four manifests**: + `plugins/zuke/.claude-plugin/plugin.json`, + `plugins/zuke/.codex-plugin/plugin.json`, the entry in + `.claude-plugin/marketplace.json`, and the root `gemini-extension.json` + (the `VERSIONED_MANIFESTS` list in `build/plugin_version_check.ts`). + Clients use the version to decide whether an installed plugin is stale, so skills edited without a bump simply never reach agents that already hold the old copy. release-please does **not** manage `plugins/` — it is not a workspace package and has no @@ -434,7 +447,7 @@ plugins/zuke/ # Claude Code plugin wrapping the skills Two gate targets hold this up, so a miss fails the build rather than shipping quietly: `pluginVersionCheck` fails when a published skill changed against the base branch and the version did not move, and `tests/plugin_manifest_test.ts` - fails when the two manifests disagree. `pluginVersionCheck` is the one part of + fails when the manifests disagree. `pluginVersionCheck` is the one part of the gate that needs history — it compares against `origin/`, or `ZUKE_PLUGIN_BASE_REF` when you set one — and it reports itself _skipped_, never passed, in a clone that has no base to compare against. diff --git a/README.md b/README.md index 996582b4..a27d606f 100644 --- a/README.md +++ b/README.md @@ -338,10 +338,34 @@ That makes `zuke-setup` and `zuke-write-build` available — they trigger automatically when you ask Claude to add Zuke to a project or write a build, and can be invoked explicitly as `/zuke:zuke-setup` and `/zuke:zuke-write-build`. +### OpenAI Codex + +The same plugin installs into Codex from this repo (it carries a Codex-native +`.agents/plugins/marketplace.json` and `.codex-plugin/plugin.json` alongside the +Claude manifests): + +```text +codex plugin marketplace add zuke-build/zuke +codex plugin add zuke@zuke +``` + +A single skill can also be pulled straight from the repo with Codex's built-in +installer skill, e.g. +`$skill-installer install https://github.com/zuke-build/zuke/tree/master/skills/zuke-write-build`. + +### Gemini CLI + +The repo doubles as a Gemini CLI extension (the root `gemini-extension.json`; +Gemini auto-discovers the `skills/` folder next to it): + +```text +gemini extensions install https://github.com/zuke-build/zuke +``` + > The `SKILL.md` content is harness-agnostic (the open -> [Agent Skills](https://agentskills.io) standard); the Claude marketplace is -> just one adapter over the shared `skills/` source. Installation for other -> harnesses (Codex, OpenCode, …) is coming later. +> [Agent Skills](https://agentskills.io) standard); each manifest above is a +> thin adapter over the shared [`skills/`](./skills) source, so every harness +> serves the same two skills. ## Documentation diff --git a/build/plugin_version_check.ts b/build/plugin_version_check.ts index 2bda6334..d2f3e531 100644 --- a/build/plugin_version_check.ts +++ b/build/plugin_version_check.ts @@ -7,8 +7,10 @@ * `plugins/zuke` is published to a Claude Code plugin marketplace, and clients * use its declared version to decide whether an installed copy is stale. It is * not a workspace package — it has no `deno.json` and release-please does not - * manage it — so the bump is manual, in two manifests, and nothing downstream - * complains when it is missed. The failure is silent in the worst way: the PR + * manage it — so the bump is manual, across every manifest in + * {@link VERSIONED_MANIFESTS} (the Claude and Codex plugin manifests, the + * marketplace entry, and the Gemini extension manifest), and nothing + * downstream complains when it is missed. The failure is silent in the worst way: the PR * is green, the skills are correct in the repository, and every agent that * already holds the old version simply never sees them. * @@ -40,6 +42,24 @@ export const PLUGIN_MANIFEST = "plugins/zuke/.claude-plugin/plugin.json"; /** The marketplace manifest listing the plugin. */ export const MARKETPLACE_MANIFEST = ".claude-plugin/marketplace.json"; +/** The Codex-native copy of the plugin manifest. */ +export const CODEX_PLUGIN_MANIFEST = "plugins/zuke/.codex-plugin/plugin.json"; + +/** The Gemini CLI extension manifest at the repo root. */ +export const GEMINI_EXTENSION_MANIFEST = "gemini-extension.json"; + +/** + * Every manifest that carries the plugin version. The bump is manual and must + * land in all of them — `tests/plugin_manifest_test.ts` fails when they + * disagree, and {@link bumpFailure} names them so the fix is one edit away. + */ +export const VERSIONED_MANIFESTS: readonly string[] = [ + PLUGIN_MANIFEST, + CODEX_PLUGIN_MANIFEST, + MARKETPLACE_MANIFEST, + GEMINI_EXTENSION_MANIFEST, +]; + /** Whether a changed path is part of what the plugin publishes. */ export function isSkillPath(path: string): boolean { return path.startsWith(SKILLS_DIR) || path.startsWith(PLUGIN_SKILLS_DIR); @@ -156,9 +176,8 @@ export function bumpFailure(verdict: BumpVerdict): string { "Clients use this version to decide whether an installed plugin is stale,", "so skills shipped without a bump never reach agents holding the old copy.", "", - `Bump the version in BOTH manifests (they must agree):`, - ` ${PLUGIN_MANIFEST}`, - ` ${MARKETPLACE_MANIFEST}`, + `Bump the version in ALL of these manifests (they must agree):`, + ...VERSIONED_MANIFESTS.map((path) => ` ${path}`), "Additive skill content is a minor bump; a correction is a patch.", ].join("\n"); } diff --git a/build/skill_check.ts b/build/skill_check.ts new file mode 100644 index 00000000..943bac96 --- /dev/null +++ b/build/skill_check.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 the Zuke contributors +// SPDX-License-Identifier: MIT + +/** + * Validates the `skills/` tree against the Agent Skills standard + * (). + * + * The skills are no longer consumed by Claude Code alone: the repo serves the + * same folders to Codex (via the plugin manifests) and to Gemini CLI (via the + * root `gemini-extension.json`, which auto-discovers `skills/`). Both of those + * harnesses — and the standard itself — require the frontmatter `name` to + * match the skill's directory name, so a rename that touches only one side + * ships a skill that silently fails to load. `skillsCheck` in `zuke.ts` runs + * this over `skills/` so the gate catches it instead. + * + * @module + */ + +/** The skills tree that every distribution surface serves. */ +export const SKILLS_ROOT = "skills"; + +/** `name` per the spec: lowercase alphanumerics and single hyphens, 1–64 chars. */ +const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +/** The spec's maximum `name` length. */ +const NAME_MAX = 64; + +/** The spec's maximum `description` length. */ +const DESCRIPTION_MAX = 1024; + +/** + * Parse a `SKILL.md`'s leading YAML frontmatter block into its single-line + * `key: value` fields, or `undefined` when the document has no frontmatter. + * + * This is deliberately not a YAML parser: the spec's required fields (`name`, + * `description`) are scalar strings, and the skills in this repo keep them on + * one line. A multi-line value is simply not seen, which for a validator errs + * on the side of reporting a field missing rather than passing garbage. + */ +export function parseFrontmatter( + text: string, +): Record | undefined { + const lines = text.split(/\r?\n/); + if (lines[0] !== "---") return undefined; + const fields: Record = {}; + for (const line of lines.slice(1)) { + if (line === "---") return fields; + const match = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]?(.*)$/.exec(line); + if (match !== null) fields[match[1]] = match[2].trim(); + } + return undefined; // Never closed — not a frontmatter block. +} + +/** + * The ways one skill document violates the Agent Skills spec, given the + * directory name it lives under. Empty means the document conforms. + */ +export function checkSkillDoc(dirName: string, text: string): string[] { + const fields = parseFrontmatter(text); + if (fields === undefined) { + return ["has no YAML frontmatter block (--- ... ---)"]; + } + const problems: string[] = []; + const name = fields.name; + if (name === undefined || name === "") { + problems.push("frontmatter is missing the required `name` field"); + } else { + if (name !== dirName) { + problems.push( + `frontmatter name "${name}" does not match the directory "${dirName}" — ` + + "Gemini CLI and the Agent Skills spec require them to be identical", + ); + } + if (!NAME_PATTERN.test(name)) { + problems.push( + `name "${name}" is not lowercase alphanumerics with single hyphens`, + ); + } + if (name.length > NAME_MAX) { + problems.push(`name is ${name.length} chars (max ${NAME_MAX})`); + } + } + const description = fields.description; + if (description === undefined || description === "") { + problems.push("frontmatter is missing the required `description` field"); + } else if (description.length > DESCRIPTION_MAX) { + problems.push( + `description is ${description.length} chars (max ${DESCRIPTION_MAX})`, + ); + } + return problems; +} + +/** + * Validate every skill folder under `root`. Returns one message per problem, + * each prefixed with the offending `SKILL.md`'s path; empty means the whole + * tree conforms to the spec. + */ +export async function checkSkillTree( + root: string = SKILLS_ROOT, +): Promise { + const problems: string[] = []; + const dirs: string[] = []; + try { + for await (const entry of Deno.readDir(root)) { + if (entry.isDirectory) dirs.push(entry.name); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return [`${root}: missing — the skills tree is gone entirely`]; + } + throw error; + } + dirs.sort(); + for (const dir of dirs) { + const doc = `${root}/${dir}/SKILL.md`; + let text: string; + try { + text = await Deno.readTextFile(doc); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + problems.push(`${doc}: missing — every skill folder needs a SKILL.md`); + continue; + } + throw error; + } + for (const problem of checkSkillDoc(dir, text)) { + problems.push(`${doc}: ${problem}`); + } + } + return problems; +} diff --git a/cspell.json b/cspell.json index f6aa3e92..0e774546 100644 --- a/cspell.json +++ b/cspell.json @@ -78,6 +78,7 @@ "exfiltrate", "extraheader", "fpath", + "frontmatter", "funcstack", "gcloud", "gcov", diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 00000000..375a6d82 --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "zuke", + "version": "0.11.0", + "description": "Agent skills for Zuke — scaffold Zuke into a project (zuke-setup) and write or edit a zuke.ts build (zuke-write-build)." +} diff --git a/plugins/zuke/.codex-plugin/plugin.json b/plugins/zuke/.codex-plugin/plugin.json new file mode 100644 index 00000000..2be7330a --- /dev/null +++ b/plugins/zuke/.codex-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "zuke", + "version": "0.11.0", + "description": "Agent skills for Zuke, the code-first build automation system for Deno/TypeScript. Bundles zuke-setup (scaffold Zuke into a project) and zuke-write-build (author or edit a zuke.ts build).", + "author": { + "name": "Zuke", + "email": "contact@zuke.build", + "url": "https://zuke.build" + }, + "homepage": "https://zuke.build", + "repository": "https://github.com/zuke-build/zuke", + "license": "MIT", + "keywords": ["zuke", "build", "deno", "typescript", "skills"], + "skills": "./skills/" +} diff --git a/tests/integration/skill_check_test.ts b/tests/integration/skill_check_test.ts new file mode 100644 index 00000000..353b31e8 --- /dev/null +++ b/tests/integration/skill_check_test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 the Zuke contributors +// SPDX-License-Identifier: MIT + +/** + * Integration: the `skillsCheck`-style target wiring — validate a skills tree + * against the Agent Skills spec and fail the build on a violation — driven + * through the real CLI, mirroring how `zuke.ts`'s own `skillsCheck` target is + * wired (see `zuke.ts` and `build/skill_check.ts`). + */ + +import { + assertEquals, + assertStringIncludes, +} from "../../packages/core/tests/_assert.ts"; +import { Build, target } from "../../packages/core/mod.ts"; +import { checkSkillTree } from "../../build/skill_check.ts"; +import { runCli } from "./_harness.ts"; + +/** A fixture build gating on spec violations under `root`, like `skillsCheck`. */ +function gateBuild(root: string) { + class Gate extends Build { + check = target().executes(async () => { + const problems = await checkSkillTree(root); + if (problems.length > 0) { + throw new Error(`violates the spec:\n ${problems.join("\n ")}`); + } + console.log("skills conform"); + }); + } + return Gate; +} + +Deno.test("skillsCheck passes a conforming tree through the CLI", async () => { + const dir = await Deno.makeTempDir(); + try { + await Deno.mkdir(`${dir}/my-skill`); + await Deno.writeTextFile( + `${dir}/my-skill/SKILL.md`, + "---\nname: my-skill\ndescription: Toggles the widget.\n---\n\n# My skill\n", + ); + + const { code, out } = await runCli(gateBuild(dir), ["check"]); + assertEquals(code, 0); + assertStringIncludes(out, "skills conform"); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("skillsCheck fails loudly on a renamed directory through the CLI", async () => { + const dir = await Deno.makeTempDir(); + try { + // The realistic slip: the folder was renamed, the frontmatter was not. + await Deno.mkdir(`${dir}/new-name`); + await Deno.writeTextFile( + `${dir}/new-name/SKILL.md`, + "---\nname: old-name\ndescription: Toggles the widget.\n---\n", + ); + + const { code, err } = await runCli(gateBuild(dir), ["check"]); + assertEquals(code, 1); + assertStringIncludes(err, "violates the spec"); + assertStringIncludes(err, '"old-name"'); + assertStringIncludes(err, '"new-name"'); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); diff --git a/tests/plugin_manifest_test.ts b/tests/plugin_manifest_test.ts index db3dad2f..09304f5c 100644 --- a/tests/plugin_manifest_test.ts +++ b/tests/plugin_manifest_test.ts @@ -2,56 +2,87 @@ // SPDX-License-Identifier: MIT /** - * Unit tests for the Claude Code plugin's two manifests — the plugin's own - * `plugins/zuke/.claude-plugin/plugin.json` and the marketplace entry in - * `.claude-plugin/marketplace.json` that points at it. + * Unit tests for the manifests that distribute the skills to the three + * harnesses: the Claude Code plugin manifest and its marketplace entry, the + * Codex-native copies of both, and the Gemini CLI extension manifest at the + * repo root. * * These are asserted here because nothing else fails when they disagree. * `pluginSyncCheck` guards the skills *content* (that the committed copies match * `skills/`), and release-please does not manage `plugins/` at all — so the - * version is bumped by hand, in two files, and a bump applied to one of them - * ships a marketplace listing whose advertised version does not match the - * plugin it installs. A stale version is worse than a wrong one: clients use it - * to decide whether an installed plugin needs re-fetching, so skills edited - * without a bump simply never reach the agents that already have the old copy. + * version is bumped by hand, across every file in `VERSIONED_MANIFESTS`, and a + * bump applied to only some of them ships a listing whose advertised version + * does not match the plugin it installs. A stale version is worse than a wrong + * one: clients use it to decide whether an installed plugin needs re-fetching, + * so skills edited without a bump simply never reach the agents that already + * have the old copy. * * @module */ import { assertEquals } from "../packages/core/tests/_assert.ts"; +import { + CODEX_PLUGIN_MANIFEST, + GEMINI_EXTENSION_MANIFEST, + MARKETPLACE_MANIFEST, + PLUGIN_MANIFEST, +} from "../build/plugin_version_check.ts"; -/** The plugin's own manifest. */ -const PLUGIN: Record = JSON.parse( - Deno.readTextFileSync("plugins/zuke/.claude-plugin/plugin.json"), -); +/** Parse one of the repo's JSON manifests. */ +function readManifest(path: string): Record { + const parsed: unknown = JSON.parse(Deno.readTextFileSync(path)); + if (!isRecord(parsed)) throw new Error(`${path} is not a JSON object`); + return parsed; +} + +/** The Claude plugin's own manifest. */ +const PLUGIN = readManifest(PLUGIN_MANIFEST); + +/** The Codex-native copy of the plugin manifest. */ +const CODEX_PLUGIN = readManifest(CODEX_PLUGIN_MANIFEST); + +/** The Claude marketplace manifest that lists the plugin. */ +const MARKETPLACE = readManifest(MARKETPLACE_MANIFEST); + +/** The Codex-native marketplace catalog. */ +const CODEX_MARKETPLACE = readManifest(".agents/plugins/marketplace.json"); -/** The marketplace manifest that lists it. */ -const MARKETPLACE: Record = JSON.parse( - Deno.readTextFileSync(".claude-plugin/marketplace.json"), -); +/** The Gemini CLI extension manifest at the repo root. */ +const GEMINI = readManifest(GEMINI_EXTENSION_MANIFEST); /** Whether a parsed JSON value is a plain object, narrowing it for field reads. */ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** The marketplace's entry for the `zuke` plugin. */ -function entry(): Record { - const plugins = MARKETPLACE.plugins; +/** A marketplace manifest's entry for the `zuke` plugin. */ +function entry(marketplace: Record): Record { + const plugins = marketplace.plugins; if (!Array.isArray(plugins)) throw new Error("marketplace has no plugins[]"); const found = plugins.filter(isRecord).find((p) => p.name === "zuke"); if (found === undefined) throw new Error("no marketplace entry named zuke"); return found; } -Deno.test("the plugin and its marketplace entry declare the same version", () => { - // The bump is manual and lives in two files. Missing one publishes a listing - // that advertises a version the plugin does not carry. +Deno.test("every version-carrying manifest declares the same version", () => { + // The bump is manual and lives in four files. Missing one publishes a + // listing that advertises a version the plugin does not carry. + const version = PLUGIN.version; assertEquals( - entry().version, - PLUGIN.version, + entry(MARKETPLACE).version, + version, "plugin.json and marketplace.json disagree on the plugin version", ); + assertEquals( + CODEX_PLUGIN.version, + version, + ".codex-plugin/plugin.json disagrees with .claude-plugin/plugin.json", + ); + assertEquals( + GEMINI.version, + version, + "gemini-extension.json disagrees with the plugin version", + ); }); Deno.test("the plugin version is a plain semver triple", () => { @@ -64,9 +95,53 @@ Deno.test("the plugin version is a plain semver triple", () => { ); }); -Deno.test("the marketplace entry points at the plugin directory in this repo", () => { +Deno.test("both marketplace manifests point at the plugin directory in this repo", () => { // A source that drifts from the real path resolves to nothing, and the // listing installs an empty plugin rather than failing loudly. - assertEquals(entry().source, "./plugins/zuke"); + assertEquals(entry(MARKETPLACE).source, "./plugins/zuke"); assertEquals(PLUGIN.name, "zuke"); + + // Codex's native catalog uses the structured source form; its path is + // relative to the marketplace root (the repo root), same as Claude's. + const source = entry(CODEX_MARKETPLACE).source; + if (!isRecord(source)) throw new Error("codex entry source is not an object"); + assertEquals(source.source, "local"); + assertEquals(source.path, "./plugins/zuke"); +}); + +Deno.test("the Codex plugin manifest mirrors the Claude one and declares its skills", () => { + // Codex resolves `.codex-plugin/plugin.json` first when present, so the copy + // must agree with the Claude manifest it shadows — and unlike hooks, the + // `skills` path has no documented auto-default, so it is declared. + assertEquals(CODEX_PLUGIN.name, PLUGIN.name); + assertEquals(CODEX_PLUGIN.description, PLUGIN.description); + assertEquals(CODEX_PLUGIN.license, PLUGIN.license); + assertEquals(CODEX_PLUGIN.skills, "./skills/"); +}); + +Deno.test("the Codex marketplace entry carries the required policy fields", () => { + // The Codex docs require policy.installation, policy.authentication, and + // category on every entry; an entry without them is not installable. + const codexEntry = entry(CODEX_MARKETPLACE); + const policy = codexEntry.policy; + if (!isRecord(policy)) throw new Error("codex entry has no policy object"); + assertEquals(policy.installation, "AVAILABLE"); + assertEquals(typeof policy.authentication, "string"); + assertEquals(typeof codexEntry.category, "string"); +}); + +Deno.test("the Gemini extension is named for the repo and finds skills/ at its root", () => { + // `gemini extensions install` requires the manifest at the repo root, and + // the extension name must match the installed directory — the repo name. + assertEquals(GEMINI.name, "zuke"); + assertEquals( + /^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(GEMINI.name)), + true, + "gemini extension names are lowercase with dashes", + ); + // Gemini auto-discovers `skills/` next to the manifest — the same tree the + // other harnesses treat as the source of truth. If it moves, the extension + // silently ships zero skills. + assertEquals(Deno.statSync("skills/zuke-setup/SKILL.md").isFile, true); + assertEquals(Deno.statSync("skills/zuke-write-build/SKILL.md").isFile, true); }); diff --git a/tests/plugin_version_check_test.ts b/tests/plugin_version_check_test.ts index 3dc112f0..5aa4642b 100644 --- a/tests/plugin_version_check_test.ts +++ b/tests/plugin_version_check_test.ts @@ -26,6 +26,7 @@ import { PLUGIN_MANIFEST, resolveBaseRef, skillPaths, + VERSIONED_MANIFESTS, } from "../build/plugin_version_check.ts"; /** A fake history: a fixed changed-file list and a fixed base manifest. */ @@ -85,12 +86,18 @@ Deno.test("skills changed and the version did not — the check fails", async () assertEquals(verdict.bumped, false); assertEquals(verdict.changed.length, 2); - // The message has to name the fix, both files, and what actually changed. + // The message has to name the fix — every version-carrying manifest, since + // a bump landed in only some of them ships disagreeing listings — and what + // actually changed. const message = bumpFailure(verdict); assertStringIncludes(message, "still 0.3.0"); assertStringIncludes(message, "skills/zuke-write-build/SKILL.md"); - assertStringIncludes(message, PLUGIN_MANIFEST); + for (const manifest of VERSIONED_MANIFESTS) { + assertStringIncludes(message, manifest); + } assertStringIncludes(message, ".claude-plugin/marketplace.json"); + assertStringIncludes(message, "plugins/zuke/.codex-plugin/plugin.json"); + assertStringIncludes(message, "gemini-extension.json"); }); Deno.test("skills changed and the version moved — the check passes", async () => { diff --git a/tests/skill_check_test.ts b/tests/skill_check_test.ts new file mode 100644 index 00000000..7d94eca8 --- /dev/null +++ b/tests/skill_check_test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 the Zuke contributors +// SPDX-License-Identifier: MIT + +/** + * Unit tests for the Agent Skills spec validator behind the `skillsCheck` + * gate target. Codex and Gemini CLI load the `skills/` folders directly, and + * both require the frontmatter `name` to match the directory — a mismatch + * ships a skill that silently fails to load, which is exactly the kind of + * quiet breakage the gate exists to catch. + * + * @module + */ + +import { + assertEquals, + assertStringIncludes, +} from "../packages/core/tests/_assert.ts"; +import { + checkSkillDoc, + checkSkillTree, + parseFrontmatter, +} from "../build/skill_check.ts"; + +/** A conforming SKILL.md body for the given name. */ +function doc(name: string, description = "Does a thing."): string { + return `---\nname: ${name}\ndescription: ${description}\n---\n\n# Body\n`; +} + +Deno.test("frontmatter parses single-line fields and rejects non-frontmatter", () => { + assertEquals(parseFrontmatter(doc("a-skill")), { + name: "a-skill", + description: "Does a thing.", + }); + // No opening fence, no fence at all, and an unclosed fence are all "none". + assertEquals(parseFrontmatter("# Just markdown\n"), undefined); + assertEquals(parseFrontmatter(""), undefined); + assertEquals(parseFrontmatter("---\nname: x\n"), undefined); + // CRLF documents parse the same as LF ones (a Windows checkout). + assertEquals(parseFrontmatter("---\r\nname: x\r\n---\r\n"), { name: "x" }); + // Lines that are not `key: value` are skipped, not fatal. + assertEquals(parseFrontmatter("---\nname: x\nnot a field\n---\n"), { + name: "x", + }); +}); + +Deno.test("a conforming document has no problems", () => { + assertEquals(checkSkillDoc("a-skill", doc("a-skill")), []); +}); + +Deno.test("a name/directory mismatch is the headline failure", () => { + const problems = checkSkillDoc("a-skill", doc("other-name")); + assertEquals(problems.length, 1); + assertStringIncludes(problems[0], '"other-name"'); + assertStringIncludes(problems[0], '"a-skill"'); +}); + +Deno.test("missing frontmatter, name, and description are each reported", () => { + assertStringIncludes(checkSkillDoc("x", "# no frontmatter\n")[0], "no YAML"); + assertStringIncludes( + checkSkillDoc("x", "---\ndescription: d\n---\n").join("\n"), + "`name`", + ); + assertStringIncludes( + checkSkillDoc("x", "---\nname: x\n---\n").join("\n"), + "`description`", + ); +}); + +Deno.test("the spec's name shape and length limits are enforced", () => { + // Uppercase, underscores, and hyphen misuse are all out of shape. + for (const bad of ["Bad", "has_underscore", "-lead", "trail-", "a--b"]) { + const problems = checkSkillDoc(bad, doc(bad)); + assertStringIncludes(problems.join("\n"), "lowercase alphanumerics"); + } + const long = "a".repeat(65); + assertStringIncludes(checkSkillDoc(long, doc(long)).join("\n"), "max 64"); + const wordy = doc("x", "d".repeat(1025)); + assertStringIncludes(checkSkillDoc("x", wordy).join("\n"), "max 1024"); +}); + +Deno.test("a tree reports missing SKILL.md files and prefixes paths", async () => { + const dir = await Deno.makeTempDir(); + try { + await Deno.mkdir(`${dir}/good`); + await Deno.writeTextFile(`${dir}/good/SKILL.md`, doc("good")); + await Deno.mkdir(`${dir}/empty`); + await Deno.mkdir(`${dir}/renamed`); + await Deno.writeTextFile(`${dir}/renamed/SKILL.md`, doc("old-name")); + // A stray file at the root is not a skill folder and is ignored. + await Deno.writeTextFile(`${dir}/README.md`, "not a skill"); + + const problems = await checkSkillTree(dir); + assertEquals(problems.length, 2); + assertStringIncludes(problems[0], `${dir}/empty/SKILL.md: missing`); + assertStringIncludes(problems[1], `${dir}/renamed/SKILL.md`); + assertStringIncludes(problems[1], '"old-name"'); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("a missing skills root is a finding, not a crash", async () => { + // The gate should say what is wrong, not die on an unhandled NotFound. + const problems = await checkSkillTree("no-such-directory"); + assertEquals(problems.length, 1); + assertStringIncludes(problems[0], "no-such-directory: missing"); +}); + +Deno.test("the repo's real skills/ tree conforms to the spec", async () => { + // The actual gate condition: what this repo serves to Claude Code, Codex, + // and Gemini CLI is valid under the standard they all consume. + assertEquals(await checkSkillTree(), []); +}); diff --git a/zuke.ts b/zuke.ts index 87b5e3bb..12c3c37f 100644 --- a/zuke.ts +++ b/zuke.ts @@ -95,6 +95,7 @@ import { checkPluginSkillsSync, syncPluginSkills, } from "./build/plugin_sync.ts"; +import { checkSkillTree } from "./build/skill_check.ts"; import { bumpFailure, checkPluginVersionBump, @@ -467,6 +468,24 @@ class ZukeBuild extends Build { ConsoleTasks.info("plugins/zuke/skills/ is in sync with skills/."); }); + skillsCheck = target() + .description( + "Validate skills/ against the Agent Skills spec (frontmatter, names)", + ) + .executes(async () => { + const problems = await checkSkillTree(); + if (problems.length > 0) { + throw new Error( + `skills/ violates the Agent Skills spec:\n ${ + problems.join("\n ") + }\n` + + "Codex and Gemini CLI load these folders directly, so a " + + "non-conforming skill silently fails to load there.", + ); + } + ConsoleTasks.info("skills/ conforms to the Agent Skills spec."); + }); + pluginVersionCheck = target() .description("Verify a skills change also bumped the plugin version") .executes(async () => { @@ -711,6 +730,7 @@ class ZukeBuild extends Build { this.snippetsCheck, this.hclSyncCheck, this.pluginSyncCheck, + this.skillsCheck, this.pluginVersionCheck, this.prBodyLint, this.actionPinCheck, From 005fbd64c91669f1518e3bb05c1049aa65cfb056 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:12:31 +0000 Subject: [PATCH 2/2] fix: harden the skills validator against YAML the harnesses reject Adversarial review of the validator found documents that passed the gate but fail in a strict YAML loader: a key with no space after the colon, duplicated keys, and block-scalar values that dodge the presence and length checks. The parser now mirrors the loader where it matters, unquotes quoted scalars, strips a leading byte-order mark, validates symlinked skill folders instead of skipping them, and reports a SKILL.md that is not a regular file as a finding rather than crashing. Each fixed case carries a regression test, and the README notes that Gemini installs the extension from the latest release snapshot. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019As9A3ugixLQiAvcat1ZKk --- README.md | 4 ++ build/skill_check.ts | 119 ++++++++++++++++++++++++++++---------- tests/skill_check_test.ts | 100 +++++++++++++++++++++++++++++--- 3 files changed, 186 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index a27d606f..2a441b86 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,10 @@ Gemini auto-discovers the `skills/` folder next to it): gemini extensions install https://github.com/zuke-build/zuke ``` +Gemini installs a GitHub extension from the repo's **latest release** snapshot +(offering a git clone as the fallback), so the extension tracks releases rather +than `master`. + > The `SKILL.md` content is harness-agnostic (the open > [Agent Skills](https://agentskills.io) standard); each manifest above is a > thin adapter over the shared [`skills/`](./skills) source, so every harness diff --git a/build/skill_check.ts b/build/skill_check.ts index 943bac96..20f66838 100644 --- a/build/skill_check.ts +++ b/build/skill_check.ts @@ -29,41 +29,86 @@ const NAME_MAX = 64; const DESCRIPTION_MAX = 1024; /** - * Parse a `SKILL.md`'s leading YAML frontmatter block into its single-line - * `key: value` fields, or `undefined` when the document has no frontmatter. + * A YAML block-scalar indicator (`>`, `|`, with optional chomping/indent + * modifiers). This validator does not resolve block scalars, so a field using + * one must be reported as uncheckable rather than validated as the one-char + * indicator it parses to. + */ +const BLOCK_SCALAR = /^[>|][+-]?[0-9]*$/; + +/** + * A `SKILL.md`'s parsed frontmatter block: the single-line fields it declares + * and any keys it repeats. + */ +export interface Frontmatter { + /** The last value seen for each `key: value` line. */ + fields: Record; + /** + * Keys that appeared more than once. Strict YAML parsers reject a document + * with duplicated keys outright, so these fail validation. + */ + duplicates: string[]; +} + +/** + * Parse a `SKILL.md`'s leading YAML frontmatter block, or `undefined` when + * the document has no closed frontmatter fence. * - * This is deliberately not a YAML parser: the spec's required fields (`name`, - * `description`) are scalar strings, and the skills in this repo keep them on - * one line. A multi-line value is simply not seen, which for a validator errs - * on the side of reporting a field missing rather than passing garbage. + * This is deliberately not a YAML parser, but it errs strict where YAML is + * strict: a `key:value` line without a space is not a YAML mapping and is not + * recorded (so a required field written that way is reported missing), a + * quoted scalar is unquoted the way a real loader would resolve it, and a + * leading UTF-8 byte-order mark is stripped the way real loaders strip it. */ -export function parseFrontmatter( - text: string, -): Record | undefined { - const lines = text.split(/\r?\n/); - if (lines[0] !== "---") return undefined; +export function parseFrontmatter(text: string): Frontmatter | undefined { + const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/); + if (lines[0]?.trimEnd() !== "---") return undefined; const fields: Record = {}; + const duplicates: string[] = []; for (const line of lines.slice(1)) { - if (line === "---") return fields; - const match = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]?(.*)$/.exec(line); - if (match !== null) fields[match[1]] = match[2].trim(); + if (line.trimEnd() === "---") return { fields, duplicates }; + // YAML requires whitespace between the colon and a value; a bare `key:` + // line is an (empty) value of its own. + const match = /^([A-Za-z][A-Za-z0-9_-]*):(?:[ \t]+(.*))?$/.exec(line); + if (match === null) continue; + const [, key, rawValue] = match; + if (key in fields) duplicates.push(key); + fields[key] = unquote((rawValue ?? "").trim()); } return undefined; // Never closed — not a frontmatter block. } +/** Strip one layer of matching single or double quotes from a scalar. */ +function unquote(value: string): string { + const first = value[0]; + if (value.length >= 2 && (first === '"' || first === "'")) { + if (value.endsWith(first)) return value.slice(1, -1); + } + return value; +} + /** * The ways one skill document violates the Agent Skills spec, given the * directory name it lives under. Empty means the document conforms. */ export function checkSkillDoc(dirName: string, text: string): string[] { - const fields = parseFrontmatter(text); - if (fields === undefined) { + const frontmatter = parseFrontmatter(text); + if (frontmatter === undefined) { return ["has no YAML frontmatter block (--- ... ---)"]; } - const problems: string[] = []; + const { fields, duplicates } = frontmatter; + const problems: string[] = duplicates.map((key) => + `frontmatter repeats the \`${key}\` key — strict YAML parsers reject ` + + "duplicated keys" + ); const name = fields.name; if (name === undefined || name === "") { problems.push("frontmatter is missing the required `name` field"); + } else if (BLOCK_SCALAR.test(name)) { + problems.push( + "`name` uses a YAML block scalar, which this validator cannot check — " + + "keep it on one line", + ); } else { if (name !== dirName) { problems.push( @@ -83,6 +128,11 @@ export function checkSkillDoc(dirName: string, text: string): string[] { const description = fields.description; if (description === undefined || description === "") { problems.push("frontmatter is missing the required `description` field"); + } else if (BLOCK_SCALAR.test(description)) { + problems.push( + "`description` uses a YAML block scalar, which this validator cannot " + + "check — keep it on one line", + ); } else if (description.length > DESCRIPTION_MAX) { problems.push( `description is ${description.length} chars (max ${DESCRIPTION_MAX})`, @@ -92,9 +142,10 @@ export function checkSkillDoc(dirName: string, text: string): string[] { } /** - * Validate every skill folder under `root`. Returns one message per problem, - * each prefixed with the offending `SKILL.md`'s path; empty means the whole - * tree conforms to the spec. + * Validate every skill folder under `root` — symlinked folders included, + * since a harness resolving the link would serve whatever it points at. + * Returns one message per problem, each prefixed with the offending + * `SKILL.md`'s path; empty means the whole tree conforms to the spec. */ export async function checkSkillTree( root: string = SKILLS_ROOT, @@ -103,7 +154,13 @@ export async function checkSkillTree( const dirs: string[] = []; try { for await (const entry of Deno.readDir(root)) { - if (entry.isDirectory) dirs.push(entry.name); + if (entry.isDirectory) { + dirs.push(entry.name); + } else if (entry.isSymlink) { + // A dangling link resolves to nothing a harness could serve; skip it. + const info = await Deno.stat(`${root}/${entry.name}`).catch(() => null); + if (info?.isDirectory === true) dirs.push(entry.name); + } } } catch (error) { if (error instanceof Deno.errors.NotFound) { @@ -114,17 +171,19 @@ export async function checkSkillTree( dirs.sort(); for (const dir of dirs) { const doc = `${root}/${dir}/SKILL.md`; - let text: string; - try { - text = await Deno.readTextFile(doc); - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - problems.push(`${doc}: missing — every skill folder needs a SKILL.md`); - continue; - } + const info = await Deno.stat(doc).catch((error: unknown) => { + if (error instanceof Deno.errors.NotFound) return null; throw error; + }); + if (info === null) { + problems.push(`${doc}: missing — every skill folder needs a SKILL.md`); + continue; + } + if (!info.isFile) { + problems.push(`${doc}: is not a regular file`); + continue; } - for (const problem of checkSkillDoc(dir, text)) { + for (const problem of checkSkillDoc(dir, await Deno.readTextFile(doc))) { problems.push(`${doc}: ${problem}`); } } diff --git a/tests/skill_check_test.ts b/tests/skill_check_test.ts index 7d94eca8..23679fe3 100644 --- a/tests/skill_check_test.ts +++ b/tests/skill_check_test.ts @@ -8,6 +8,10 @@ * ships a skill that silently fails to load, which is exactly the kind of * quiet breakage the gate exists to catch. * + * The parser cases mirror strict YAML where it matters: a document that a + * real loader would reject (no space after the colon, duplicated keys) must + * not pass here just because a naive line parser could make sense of it. + * * @module */ @@ -28,21 +32,49 @@ function doc(name: string, description = "Does a thing."): string { Deno.test("frontmatter parses single-line fields and rejects non-frontmatter", () => { assertEquals(parseFrontmatter(doc("a-skill")), { - name: "a-skill", - description: "Does a thing.", + fields: { name: "a-skill", description: "Does a thing." }, + duplicates: [], }); // No opening fence, no fence at all, and an unclosed fence are all "none". assertEquals(parseFrontmatter("# Just markdown\n"), undefined); assertEquals(parseFrontmatter(""), undefined); assertEquals(parseFrontmatter("---\nname: x\n"), undefined); // CRLF documents parse the same as LF ones (a Windows checkout). - assertEquals(parseFrontmatter("---\r\nname: x\r\n---\r\n"), { name: "x" }); + assertEquals(parseFrontmatter("---\r\nname: x\r\n---\r\n")?.fields, { + name: "x", + }); // Lines that are not `key: value` are skipped, not fatal. - assertEquals(parseFrontmatter("---\nname: x\nnot a field\n---\n"), { + assertEquals(parseFrontmatter("---\nname: x\nnot a field\n---\n")?.fields, { name: "x", }); }); +Deno.test("the parser mirrors what a real YAML loader would resolve", () => { + // Loaders strip a leading BOM before looking for the fence. + assertEquals(parseFrontmatter("---\nname: x\n---\n")?.fields, { + name: "x", + }); + // A fence with trailing whitespace still closes the block. + assertEquals(parseFrontmatter("---\nname: x\n--- \n")?.fields, { name: "x" }); + // A quoted scalar resolves to its content, not to the quoted text. + assertEquals(parseFrontmatter('---\nname: "x"\n---\n')?.fields, { + name: "x", + }); + assertEquals(parseFrontmatter("---\nname: 'x'\n---\n")?.fields, { + name: "x", + }); + // `key:value` without a space is NOT a YAML mapping — the field must not be + // recorded, or a document every harness rejects would pass the gate. + assertEquals(parseFrontmatter("---\nname:x\n---\n")?.fields, {}); + // A bare `key:` line is an empty value, which the doc checks treat as missing. + assertEquals(parseFrontmatter("---\nname:\n---\n")?.fields, { name: "" }); + // Duplicated keys are recorded — strict YAML parsers reject the document. + assertEquals( + parseFrontmatter("---\nname: a\nname: b\n---\n")?.duplicates, + ["name"], + ); +}); + Deno.test("a conforming document has no problems", () => { assertEquals(checkSkillDoc("a-skill", doc("a-skill")), []); }); @@ -64,6 +96,32 @@ Deno.test("missing frontmatter, name, and description are each reported", () => checkSkillDoc("x", "---\nname: x\n---\n").join("\n"), "`description`", ); + // `name:x` with no space is not a mapping in YAML, so `name` is missing. + assertStringIncludes( + checkSkillDoc("x", "---\nname:x\ndescription: d\n---\n").join("\n"), + "`name`", + ); +}); + +Deno.test("YAML the validator cannot resolve is a finding, not a pass", () => { + // A block-scalar description resolves to kilobytes in a real loader; the + // naive value is the one-char indicator, which must not sail through the + // presence and length checks. + const folded = "---\nname: x\ndescription: >\n long text\n---\n"; + assertStringIncludes( + checkSkillDoc("x", folded).join("\n"), + "block scalar", + ); + assertStringIncludes( + checkSkillDoc("x", "---\nname: |\ndescription: d\n---\n").join("\n"), + "block scalar", + ); + // Duplicated keys fail loudly, matching the strict parsers that reject them. + assertStringIncludes( + checkSkillDoc("x", "---\nname: x\nname: x\ndescription: d\n---\n") + .join("\n"), + "repeats", + ); }); Deno.test("the spec's name shape and length limits are enforced", () => { @@ -88,17 +146,45 @@ Deno.test("a tree reports missing SKILL.md files and prefixes paths", async () = await Deno.writeTextFile(`${dir}/renamed/SKILL.md`, doc("old-name")); // A stray file at the root is not a skill folder and is ignored. await Deno.writeTextFile(`${dir}/README.md`, "not a skill"); + // A SKILL.md that is not a regular file is a finding, not a crash. + await Deno.mkdir(`${dir}/odd/SKILL.md`, { recursive: true }); const problems = await checkSkillTree(dir); - assertEquals(problems.length, 2); + assertEquals(problems.length, 3); assertStringIncludes(problems[0], `${dir}/empty/SKILL.md: missing`); - assertStringIncludes(problems[1], `${dir}/renamed/SKILL.md`); - assertStringIncludes(problems[1], '"old-name"'); + assertStringIncludes(problems[1], `${dir}/odd/SKILL.md`); + assertStringIncludes(problems[1], "not a regular file"); + assertStringIncludes(problems[2], `${dir}/renamed/SKILL.md`); + assertStringIncludes(problems[2], '"old-name"'); } finally { await Deno.remove(dir, { recursive: true }); } }); +Deno.test({ + name: "a symlinked skill folder is validated, not skipped", + // Creating symlinks on Windows needs a privilege the CI runner may lack. + ignore: Deno.build.os === "windows", + fn: async () => { + const dir = await Deno.makeTempDir(); + try { + await Deno.mkdir(`${dir}/real`); + await Deno.writeTextFile(`${dir}/real/SKILL.md`, doc("wrong-name")); + await Deno.mkdir(`${dir}/tree`); + await Deno.symlink(`${dir}/real`, `${dir}/tree/linked`); + // A dangling link resolves to nothing a harness could serve. + await Deno.symlink(`${dir}/gone`, `${dir}/tree/dangling`); + + const problems = await checkSkillTree(`${dir}/tree`); + assertEquals(problems.length, 1); + assertStringIncludes(problems[0], `${dir}/tree/linked/SKILL.md`); + assertStringIncludes(problems[0], '"wrong-name"'); + } finally { + await Deno.remove(dir, { recursive: true }); + } + }, +}); + Deno.test("a missing skills root is a finding, not a crash", async () => { // The gate should say what is wrong, not die on an unhandled NotFound. const problems = await checkSkillTree("no-such-directory");