#!/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();