mirror of
https://github.com/duthaho/claudekit.git
synced 2026-07-14 02:55:18 +03:00
Merge pull request #1 from duthaho/feat/validate-plugin
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
# AGENTS.md
|
||||
<!-- mapped 2026-07-12 @ 52e2cd6 · regenerate with /map refresh -->
|
||||
|
||||
## 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 <path-or-repo>` → `/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/<name>/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/<name>.md` — YAML frontmatter (`name`, `description` with embedded
|
||||
`<example>` blocks as an escaped single-line string, `tools`, `memory: project`)
|
||||
+ system prompt body. See `agents/tester.md`.
|
||||
- `output-styles/<name>.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
|
||||
@@ -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+
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Repo validator: lints every skills/<name>/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) {
|
||||
// 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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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) {
|
||||
// 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 } = parsed;
|
||||
const body = stripFences(parsed.body);
|
||||
|
||||
for (const key of REQUIRED_FRONTMATTER) {
|
||||
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}"`);
|
||||
}
|
||||
|
||||
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})`);
|
||||
}
|
||||
|
||||
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(
|
||||
".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;
|
||||
|
||||
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);
|
||||
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)) {
|
||||
fail(`skills/${dirName}/`, "missing SKILL.md");
|
||||
continue;
|
||||
}
|
||||
skillCount++;
|
||||
checkSkill(dirName, rel, fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
if (skillCount === 0 && errors.length === 0) {
|
||||
fail("skills/", "no skills found — wrong repo root?");
|
||||
}
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user