From e293afff1fb73259802ead69fa2f9c7691b756f4 Mon Sep 17 00:00:00 2001 From: duthaho Date: Sun, 12 Jul 2026 13:09:00 +0000 Subject: [PATCH 1/3] feat: add validate-plugin script linting SKILL.md anatomy + version sync Checks every skills/*/SKILL.md for required frontmatter (name matching directory, description, user-invocable) and the 8-section anatomy from README (init is anatomy-exempt as the documented off-spine skill), plus plugin.json/marketplace.json version sync. Zero deps, exits 1 on any violation. --- scripts/validate-plugin.cjs | 167 ++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 scripts/validate-plugin.cjs diff --git a/scripts/validate-plugin.cjs b/scripts/validate-plugin.cjs new file mode 100644 index 0000000..ed895e4 --- /dev/null +++ b/scripts/validate-plugin.cjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * Repo validator: lints every skills//SKILL.md against the 8-section + * anatomy documented in README.md ("Skill anatomy"), and checks that the + * plugin version is in sync between .claude-plugin/plugin.json and + * .claude-plugin/marketplace.json. + * + * CI-runnable, zero dependencies. Exits 1 on any violation (fail loud — + * unlike the hook scripts, this is a gate, not a convenience). + * + * Usage: node scripts/validate-plugin.cjs [repo-root] + */ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.resolve(process.argv[2] || path.join(__dirname, "..")); + +// Skills exempt from the 8-section anatomy (off-spine setup skills — see +// README.md "The 5-phase spine"). Frontmatter checks still apply. +const ANATOMY_EXEMPT = new Set(["init"]); + +// Required ## sections, in relative order (extra sections are allowed). +const REQUIRED_SECTIONS = [ + "Overview", + "When to Use", + "When NOT to Use", + "Process", + "Rationalizations", + "Evidence Requirements", + "Red Flags", + "References", +]; + +const REQUIRED_FRONTMATTER = ["name", "description", "user-invocable"]; + +const errors = []; +const fail = (file, msg) => errors.push(`${file}: ${msg}`); + +// --- Skill checks --------------------------------------------------------- + +function parseFrontmatter(file, text) { + const lines = text.split("\n"); + if (lines[0].trim() !== "---") { + fail(file, "missing YAML frontmatter (file must start with ---)"); + return null; + } + const end = lines.indexOf("---", 1); + if (end === -1) { + fail(file, "unterminated YAML frontmatter"); + return null; + } + const keys = {}; + for (const line of lines.slice(1, end)) { + const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(.*)$/); // top-level keys only + if (m) keys[m[1]] = m[2].trim(); + } + return { keys, body: lines.slice(end + 1).join("\n") }; +} + +function checkSkill(dirName, file, text) { + const parsed = parseFrontmatter(file, text); + if (!parsed) return; + const { keys, body } = parsed; + + for (const key of REQUIRED_FRONTMATTER) { + if (!(key in keys)) fail(file, `frontmatter missing required key "${key}"`); + } + if (keys.name && keys.name !== dirName) { + fail(file, `frontmatter name "${keys.name}" does not match directory name "${dirName}"`); + } + + if (ANATOMY_EXEMPT.has(dirName)) return; + + const sections = [...body.matchAll(/^## (.+?)\s*$/gm)].map((m) => m[1]); + + // Required sections must all be present, in relative order. + let cursor = 0; + for (const required of REQUIRED_SECTIONS) { + const idx = sections.indexOf(required, cursor); + if (idx === -1) { + if (sections.includes(required)) { + fail(file, `section "## ${required}" is out of order (expected after "${REQUIRED_SECTIONS[REQUIRED_SECTIONS.indexOf(required) - 1] || "start"}")`); + } else { + fail(file, `missing required section "## ${required}"`); + } + } else { + cursor = idx + 1; + } + } + + // Rationalizations must contain a markdown table (README requires a table + // of excuses with rebuttals). + const ration = body.split(/^## Rationalizations\s*$/m)[1]; + if (ration !== undefined) { + const sectionBody = ration.split(/^## /m)[0]; + if (!/^\s*\|.+\|\s*$/m.test(sectionBody)) { + fail(file, 'section "## Rationalizations" contains no markdown table'); + } + } +} + +// --- Version sync check --------------------------------------------------- + +function checkVersionSync() { + const pluginPath = path.join(ROOT, ".claude-plugin", "plugin.json"); + const marketPath = path.join(ROOT, ".claude-plugin", "marketplace.json"); + let plugin, market; + try { + plugin = JSON.parse(fs.readFileSync(pluginPath, "utf8")); + } catch (e) { + return fail(".claude-plugin/plugin.json", `unreadable or invalid JSON (${e.message})`); + } + try { + market = JSON.parse(fs.readFileSync(marketPath, "utf8")); + } catch (e) { + return fail(".claude-plugin/marketplace.json", `unreadable or invalid JSON (${e.message})`); + } + + const entry = (market.plugins || []).find((p) => p.name === plugin.name); + if (!entry) { + return fail( + ".claude-plugin/marketplace.json", + `no plugins[] entry named "${plugin.name}" (plugin.json name)` + ); + } + if (entry.version !== plugin.version) { + fail( + ".claude-plugin/marketplace.json", + `version "${entry.version}" out of sync with plugin.json version "${plugin.version}"` + ); + } +} + +// --- Run ------------------------------------------------------------------ + +function main() { + const skillsDir = path.join(ROOT, "skills"); + let skillCount = 0; + + for (const dirName of fs.readdirSync(skillsDir).sort()) { + const dir = path.join(skillsDir, dirName); + if (!fs.statSync(dir).isDirectory()) continue; + const file = path.join(dir, "SKILL.md"); + const rel = path.relative(ROOT, file); + if (!fs.existsSync(file)) { + fail(`skills/${dirName}/`, "missing SKILL.md"); + continue; + } + skillCount++; + checkSkill(dirName, rel, fs.readFileSync(file, "utf8")); + } + + checkVersionSync(); + + if (errors.length > 0) { + console.error(`FAIL — ${errors.length} violation(s):\n`); + for (const e of errors) console.error(` ✗ ${e}`); + process.exit(1); + } + console.log( + `OK — ${skillCount} skills validated (${ANATOMY_EXEMPT.size} anatomy-exempt), plugin/marketplace versions in sync.` + ); +} + +main(); From c7ffc58fe37d98c86c82a6f299b4a94e1cb768d1 Mon Sep 17 00:00:00 2001 From: duthaho Date: Sun, 12 Jul 2026 13:09:52 +0000 Subject: [PATCH 2/3] feat: run validate-plugin in CI; document the check in README and AGENTS.md --- .github/workflows/validate.yml | 14 +++++++++ AGENTS.md | 54 ++++++++++++++++++++++++++++++++++ README.md | 10 +++++++ 3 files changed, 78 insertions(+) create mode 100644 .github/workflows/validate.yml create mode 100644 AGENTS.md diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..7409c6f --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,14 @@ +name: validate + +on: + push: + branches: [main] + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate skill anatomy + version sync + run: node scripts/validate-plugin.cjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..96d62d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# AGENTS.md + + +## What this is +claudekit — a Claude Code **plugin** (not an app): 15 skills, 8 agents, 5 output +styles, and 3 hook scripts, distributed via a plugin marketplace. Almost everything +is markdown consumed by Claude Code at runtime; the only buildable code is the +docs website (`website/`, Astro Starlight). The only repo-level check is +`scripts/validate-plugin.cjs` (skill anatomy + version sync). + +## Build, test, run +- Plugin: nothing to build. Lint before committing: `node scripts/validate-plugin.cjs` + — validates every SKILL.md against the 8-section anatomy and plugin/marketplace + version sync (also runs in CI, `.github/workflows/validate.yml`). +- Test by installing locally: + `/plugin marketplace add ` → `/plugin install claudekit` → `/claudekit:init` +- Website (`cd website/`): `npm install`, `npm run dev`, `npm run build`, + `npm run preview`. Deployed to Cloudflare via `wrangler.jsonc` (assets from `./dist`). + +## Architecture +- `.claude-plugin/plugin.json` — plugin manifest; `.claude-plugin/marketplace.json` + — marketplace listing. **Version lives in both files; bump them together.** +- `skills//SKILL.md` — one dir per skill. Only `skills/init/` has extras: + `templates/` (hooks.json, mcp-servers.json, rules/*.md) that init scaffolds into + a user's `.claude/` dir. +- `agents/.md` — YAML frontmatter (`name`, `description` with embedded + `` blocks as an escaped single-line string, `tools`, `memory: project`) + + system prompt body. See `agents/tester.md`. +- `output-styles/.md` — frontmatter must keep `keep-coding-instructions: true`. +- `scripts/*.cjs` — Node hook scripts (PostToolUse etc.); they **fail open** — + errors are swallowed by design (see `scripts/auto-format.cjs`). +- `website/src/content/docs/` — docs pages; `reference/{skills,agents,output-styles,mcp-servers}.md` + mirror the plugin contents. + +## Conventions & gotchas +- Every skill follows a fixed 8-section anatomy (frontmatter, Overview, When to + Use / NOT, Process, **Rationalizations table**, **Evidence Requirements**, Red + Flags, References) — documented in README.md "Skill anatomy". New skills must match. +- Counts and tables ("15 skills", "8 agents", workflow chains) are restated in + `README.md`, `CHANGELOG.md`, and `website/src/content/docs/reference/*` — adding + or removing a skill/agent means updating all three. +- Skill frontmatter descriptions carry trigger keywords ("Use when…", "Activate + for…") — they are load-bearing for skill dispatch, not doc prose. +- `CHANGELOG.md` follows Keep a Changelog + SemVer; releases go through the + repo's own `skills/release-and-changelog/SKILL.md` discipline. +- House style: evidence-first, no marketing voice (README.md "No founder voice") — + keep that tone in any skill/agent/doc text. + +## Landmarks +- `README.md` — canonical overview: 5-phase spine, agent roster, workflow chains +- `.claude-plugin/plugin.json` — manifest / version +- `skills/init/SKILL.md` — the setup wizard; templates in `skills/init/templates/` +- `agents/tester.md` — reference example of agent file format +- `website/astro.config.mjs` — docs site config diff --git a/README.md b/README.md index da6a5d2..2249fae 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,16 @@ For library/plugin authors before tagging. The audit catches stale deps and unac In practice, devs skip steps for trivial work. The chains show the full discipline; use what the task earns. +## Development + +CI (`.github/workflows/validate.yml`) lints every skill against the 8-section +anatomy above and checks `plugin.json`/`marketplace.json` version sync. Run +locally before committing: + +``` +node scripts/validate-plugin.cjs +``` + ## Requirements - Claude Code 1.0+ From c6a7817f885ccde4abf403af52ff97df515aa3f4 Mon Sep 17 00:00:00 2001 From: duthaho Date: Sun, 12 Jul 2026 14:02:14 +0000 Subject: [PATCH 3/3] fix: harden validate-plugin against review findings - normalize CRLF and match the closing frontmatter delimiter tolerantly (no false CI failures on Windows-committed files; no latching onto a later horizontal rule) - strip fenced code blocks before section/table scanning so embedded anatomy examples can't fool the checks - strip quotes/inline comments from frontmatter values; reject empty required values - guard readdir/stat so a wrong root or dangling symlink reports instead of crashing; fail on zero skills found - require plugin.json version to exist (undefined===undefined no longer passes as in sync) --- scripts/validate-plugin.cjs | 65 +++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/scripts/validate-plugin.cjs b/scripts/validate-plugin.cjs index ed895e4..aaa3d27 100644 --- a/scripts/validate-plugin.cjs +++ b/scripts/validate-plugin.cjs @@ -41,12 +41,15 @@ const fail = (file, msg) => errors.push(`${file}: ${msg}`); // --- Skill checks --------------------------------------------------------- function parseFrontmatter(file, text) { - const lines = text.split("\n"); + // Normalize CRLF so Windows-committed files don't false-fail. + const lines = text.replace(/\r\n/g, "\n").split("\n"); if (lines[0].trim() !== "---") { fail(file, "missing YAML frontmatter (file must start with ---)"); return null; } - const end = lines.indexOf("---", 1); + // Match the closing delimiter tolerantly (trailing whitespace), so we never + // latch onto a later "---" horizontal rule in the body. + const end = lines.findIndex((l, i) => i > 0 && l.trim() === "---"); if (end === -1) { fail(file, "unterminated YAML frontmatter"); return null; @@ -54,18 +57,47 @@ function parseFrontmatter(file, text) { const keys = {}; for (const line of lines.slice(1, end)) { const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(.*)$/); // top-level keys only - if (m) keys[m[1]] = m[2].trim(); + if (m) { + // Drop inline comments, then surrounding quotes ("x" / 'x'). + let v = m[2].replace(/\s#.*$/, "").trim(); + const q = v.match(/^(["'])(.*)\1$/); + if (q) v = q[2]; + keys[m[1]] = v; + } } return { keys, body: lines.slice(end + 1).join("\n") }; } +// Blank out fenced code blocks so example headings/tables inside ``` fences +// don't fool the section checks (line count is preserved). +function stripFences(body) { + let inFence = false; + return body + .split("\n") + .map((line) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + return ""; + } + return inFence ? "" : line; + }) + .join("\n"); +} + function checkSkill(dirName, file, text) { const parsed = parseFrontmatter(file, text); if (!parsed) return; - const { keys, body } = parsed; + const { keys } = parsed; + const body = stripFences(parsed.body); for (const key of REQUIRED_FRONTMATTER) { - if (!(key in keys)) fail(file, `frontmatter missing required key "${key}"`); + if (!(key in keys)) { + fail(file, `frontmatter missing required key "${key}"`); + } else if (keys[key] === "" && key !== "description") { + // description is often a folded block (`description: >`), so an empty + // inline value is legitimate there; name/user-invocable must be inline. + fail(file, `frontmatter key "${key}" has an empty value`); + } } if (keys.name && keys.name !== dirName) { fail(file, `frontmatter name "${keys.name}" does not match directory name "${dirName}"`); @@ -118,6 +150,9 @@ function checkVersionSync() { return fail(".claude-plugin/marketplace.json", `unreadable or invalid JSON (${e.message})`); } + if (!plugin.version || typeof plugin.version !== "string") { + return fail(".claude-plugin/plugin.json", 'missing or empty "version"'); + } const entry = (market.plugins || []).find((p) => p.name === plugin.name); if (!entry) { return fail( @@ -139,9 +174,22 @@ function main() { const skillsDir = path.join(ROOT, "skills"); let skillCount = 0; - for (const dirName of fs.readdirSync(skillsDir).sort()) { + let dirNames = []; + try { + dirNames = fs.readdirSync(skillsDir).sort(); + } catch (e) { + fail("skills/", `unreadable directory (${e.message}) — wrong repo root?`); + } + for (const dirName of dirNames) { const dir = path.join(skillsDir, dirName); - if (!fs.statSync(dir).isDirectory()) continue; + let stat; + try { + stat = fs.statSync(dir); // throws on dangling symlinks + } catch (e) { + fail(`skills/${dirName}`, `unreadable entry (${e.message})`); + continue; + } + if (!stat.isDirectory()) continue; const file = path.join(dir, "SKILL.md"); const rel = path.relative(ROOT, file); if (!fs.existsSync(file)) { @@ -151,6 +199,9 @@ function main() { skillCount++; checkSkill(dirName, rel, fs.readFileSync(file, "utf8")); } + if (skillCount === 0 && errors.length === 0) { + fail("skills/", "no skills found — wrong repo root?"); + } checkVersionSync();