| #!/usr/bin/env node |
| // Generate per-family skill counts from a magpie `skills/` directory. |
| // |
| // The website's skill-family counts used to be hand-edited in |
| // ImmersiveGradientHero.tsx and drifted every time a skill landed upstream. |
| // This script derives the counts from the actual SKILL.md directories so they |
| // stay in sync with apache/magpie. It is invoked by scripts/sync-docs.sh after |
| // the framework repo is checked out. |
| // |
| // Family membership is read straight from each skill's `family:` frontmatter |
| // key — the single source of truth apache/magpie declares (skills/setup/SKILL.md |
| // Golden rule 8; validated against ALLOWED_FAMILIES in the skill-and-tool- |
| // validator). It is never inferred from the skill's name prefix: a name-prefix |
| // heuristic cannot express families that span prefixes (e.g. contributor-growth, |
| // repo-health) and silently mis-classified skills whenever upstream added one. |
| // |
| // Usage: node gen-skill-counts.mjs <skills-dir> <out-json> |
| // |
| // Output is deterministic (no timestamps) so the committed JSON only changes |
| // when the counts actually change. |
| |
| import { |
| readdirSync, |
| statSync, |
| existsSync, |
| readFileSync, |
| writeFileSync, |
| } from "node:fs"; |
| import { join } from "node:path"; |
| |
| const [, , skillsDir, outPath] = process.argv; |
| if (!skillsDir || !outPath) { |
| console.error("usage: gen-skill-counts.mjs <skills-dir> <out-json>"); |
| process.exit(1); |
| } |
| |
| // Read the `family:` frontmatter key from a skill's SKILL.md. Returns the |
| // declared family, or null when the SKILL.md is absent/unreadable or declares |
| // no family (mirrors read_family() in setup-status/collect_status.py). Family |
| // is never inferred from the name prefix. |
| function readFamily(name) { |
| let text; |
| try { |
| text = readFileSync(join(skillsDir, name, "SKILL.md"), "utf8"); |
| } catch { |
| return null; |
| } |
| if (!text.startsWith("---")) return null; |
| const end = text.indexOf("\n---", 3); |
| const frontmatter = end !== -1 ? text.slice(3, end) : text; |
| for (const raw of frontmatter.split("\n")) { |
| const line = raw.trim(); |
| if (line.startsWith("family:")) { |
| return line.slice("family:".length).trim() || null; |
| } |
| } |
| return null; |
| } |
| |
| const dirs = readdirSync(skillsDir) |
| .filter((d) => { |
| try { |
| return ( |
| statSync(join(skillsDir, d)).isDirectory() && |
| existsSync(join(skillsDir, d, "SKILL.md")) |
| ); |
| } catch { |
| return false; |
| } |
| }) |
| .sort(); |
| |
| const counts = {}; |
| const uncategorized = []; |
| for (const name of dirs) { |
| const family = readFamily(name); |
| if (!family) { |
| uncategorized.push(name); |
| continue; |
| } |
| counts[family] = (counts[family] || 0) + 1; |
| } |
| |
| if (uncategorized.length) { |
| console.warn( |
| `⚠ ${uncategorized.length} skill(s) declare no family: ${uncategorized.join(", ")}`, |
| ); |
| } |
| |
| // Sort family keys for stable, diff-friendly output. |
| const sortedCounts = Object.fromEntries( |
| Object.keys(counts) |
| .sort() |
| .map((k) => [k, counts[k]]), |
| ); |
| |
| const out = { |
| _comment: |
| "GENERATED by scripts/gen-skill-counts.mjs — do not edit by hand. Counts derive from the `family:` frontmatter of apache/magpie skills/.", |
| total: dirs.length, |
| uncategorized, |
| counts: sortedCounts, |
| }; |
| |
| writeFileSync(outPath, JSON.stringify(out, null, 2) + "\n"); |
| console.log(`✓ skill counts → ${outPath}: ${JSON.stringify(sortedCounts)}`); |