Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
34 changes: 23 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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/<PR base>`, 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.
Expand Down
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 24 additions & 5 deletions build/plugin_version_check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
Expand Down
191 changes: 191 additions & 0 deletions build/skill_check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright (c) 2026 the Zuke contributors
// SPDX-License-Identifier: MIT

/**
* Validates the `skills/` tree against the Agent Skills standard
* (<https://agentskills.io/specification>).
*
* 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<string, string>;
/**
* 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<string, string> = {};
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<string[]> {
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;
}
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"exfiltrate",
"extraheader",
"fpath",
"frontmatter",
"funcstack",
"gcloud",
"gcov",
Expand Down
Loading