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 820fd7cf..46f93fb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -308,10 +308,10 @@ 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`, `graphDocCheck`, -`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. +`snippetsCheck`, `hclSyncCheck`, `pluginSyncCheck`, `skillsCheck`, +`graphDocCheck`, `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. **The lock is part of the gate.** Every entrypoint that loads `zuke.ts` — both launchers and the root tasks — passes `--frozen`, so a run cannot quietly heal a @@ -342,7 +342,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 +414,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 +431,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 +446,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 6069c9f5..fa69f2dc 100644 --- a/README.md +++ b/README.md @@ -340,10 +340,38 @@ 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 +``` + +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); 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..20f66838 --- /dev/null +++ b/build/skill_check.ts @@ -0,0 +1,191 @@ +// 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; + +/** + * 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, 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): 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.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 frontmatter = parseFrontmatter(text); + if (frontmatter === undefined) { + return ["has no YAML frontmatter block (--- ... ---)"]; + } + 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( + `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 (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})`, + ); + } + return problems; +} + +/** + * 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, +): Promise { + const problems: string[] = []; + const dirs: string[] = []; + try { + for await (const entry of Deno.readDir(root)) { + 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) { + return [`${root}: missing — the skills tree is gone entirely`]; + } + throw error; + } + dirs.sort(); + for (const dir of dirs) { + const doc = `${root}/${dir}/SKILL.md`; + 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, await Deno.readTextFile(doc))) { + 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/docs/graph.md b/docs/graph.md index b00c6f36..1b05fb6a 100644 --- a/docs/graph.md +++ b/docs/graph.md @@ -4,7 +4,7 @@ The dependency graph of [`zuke.ts`](../zuke.ts) — Zuke building itself. An arrow points from a dependency to the target that depends on it, so a target runs after everything that points at it. This is the same graph `./zuke graph` prints as text and `./zuke graph --output=html` renders interactively. -38 target(s), 24 dependency edge(s). Regenerate with `./zuke graphDoc`; the `graphDocCheck` target in the CI gate fails when this page drifts from the build. +39 target(s), 25 dependency edge(s). Regenerate with `./zuke graphDoc`; the `graphDocCheck` target in the CI gate fails when this page drifts from the build. ```mermaid flowchart TD @@ -28,48 +28,50 @@ flowchart TD t17["hclSyncCheck"] t18["pluginSync"] t19["pluginSyncCheck"] - t20["graphDoc"] - t21["graphDocCheck"] - t22["pluginVersionCheck"] - t23["prBodyLint"] - t24["coreFloorCheck"] - t25["lockCheck"] - t26["security"] - t27["actionPinCheck"] - t28["ci"] - t29["scorecardSarif"] - t30["codeql"] - t31["reviewBase"] - t32["review"] - t33["release"] - t34["actionRelease"] - t35["publishJsr"] - t36["publish"] - t37["default"] + t20["skillsCheck"] + t21["graphDoc"] + t22["graphDocCheck"] + t23["pluginVersionCheck"] + t24["prBodyLint"] + t25["coreFloorCheck"] + t26["lockCheck"] + t27["security"] + t28["actionPinCheck"] + t29["ci"] + t30["scorecardSarif"] + t31["codeql"] + t32["reviewBase"] + t33["review"] + t34["release"] + t35["actionRelease"] + t36["publishJsr"] + t37["publish"] + t38["default"] t1 --> t5 t5 --> t6 t6 --> t8 t8 --> t9 - t2 --> t28 - t3 --> t28 - t4 --> t28 - t8 --> t28 - t9 --> t28 - t11 --> t28 - t14 --> t28 - t15 --> t28 - t17 --> t28 - t19 --> t28 - t21 --> t28 - t22 --> t28 - t23 --> t28 - t27 --> t28 - t26 --> t28 - t25 --> t28 - t31 --> t32 - t33 --> t36 - t35 --> t36 - t28 --> t37 + t2 --> t29 + t3 --> t29 + t4 --> t29 + t8 --> t29 + t9 --> t29 + t11 --> t29 + t14 --> t29 + t15 --> t29 + t17 --> t29 + t19 --> t29 + t20 --> t29 + t22 --> t29 + t23 --> t29 + t24 --> t29 + t28 --> t29 + t27 --> t29 + t26 --> t29 + t32 --> t33 + t34 --> t37 + t36 --> t37 + t29 --> t38 ``` ## Targets @@ -96,6 +98,7 @@ flowchart TD | `hclSyncCheck` | Verify the Terraform/OpenTofu wrappers match their template | — | | `pluginSync` | Sync plugins/zuke/skills/ from skills/ (real copies, not a symlink) | — | | `pluginSyncCheck` | Verify plugins/zuke/skills/ matches skills/ (no drift) | — | +| `skillsCheck` | Validate skills/ against the Agent Skills spec (frontmatter, names) | — | | `graphDoc` | Regenerate docs/graph.md — this build's graph as a Mermaid page | — | | `graphDocCheck` | Verify docs/graph.md matches the current build graph | — | | `pluginVersionCheck` | Verify a skills change also bumped the plugin version | — | @@ -104,7 +107,7 @@ flowchart TD | `lockCheck` | Verify the run did not rewrite deno.lock | — | | `security` | Run supply-chain security scanners (zuke/security) | — | | `actionPinCheck` | Verify the workflows only use inputs the released action has | — | -| `ci` | Full pre-commit / CI gate | `format`, `lint`, `spell`, `coverage`, `coverageUpload`, `apiDocsCheck`, `docLint`, `snippetsCheck`, `hclSyncCheck`, `pluginSyncCheck`, `graphDocCheck`, `pluginVersionCheck`, `prBodyLint`, `actionPinCheck`, `security`, `lockCheck` | +| `ci` | Full pre-commit / CI gate | `format`, `lint`, `spell`, `coverage`, `coverageUpload`, `apiDocsCheck`, `docLint`, `snippetsCheck`, `hclSyncCheck`, `pluginSyncCheck`, `skillsCheck`, `graphDocCheck`, `pluginVersionCheck`, `prBodyLint`, `actionPinCheck`, `security`, `lockCheck` | | `scorecardSarif` | Upload the Scorecard SARIF to GitHub code scanning | — | | `codeql` | CodeQL static analysis (runs in CI via codeql.yml) | — | | `reviewBase` | Fetch the base branch the AI review diffs against | — | 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..23679fe3 --- /dev/null +++ b/tests/skill_check_test.ts @@ -0,0 +1,199 @@ +// 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. + * + * 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 + */ + +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")), { + 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")?.fields, { + name: "x", + }); + // Lines that are not `key: value` are skipped, not fatal. + 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")), []); +}); + +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`", + ); + // `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", () => { + // 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"); + // 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, 3); + assertStringIncludes(problems[0], `${dir}/empty/SKILL.md: missing`); + 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"); + 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 9154a095..a251929e 100644 --- a/zuke.ts +++ b/zuke.ts @@ -101,6 +101,7 @@ import { checkPluginSkillsSync, syncPluginSkills, } from "./build/plugin_sync.ts"; +import { checkSkillTree } from "./build/skill_check.ts"; import { bumpFailure, checkPluginVersionBump, @@ -473,6 +474,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."); + }); + graphDoc = target() .description( "Regenerate docs/graph.md — this build's graph as a Mermaid page", @@ -743,6 +762,7 @@ class ZukeBuild extends Build { this.snippetsCheck, this.hclSyncCheck, this.pluginSyncCheck, + this.skillsCheck, this.graphDocCheck, this.pluginVersionCheck, this.prBodyLint,