Files
pm-claude-skills/web/build-skills.mjs
T
Mohit 54f76456ab feat: workflow recipes, eval badges, one-click MCP, playground upgrades, sample gallery, skill-of-the-week
Signature features that turn breadth (174 skills) into a differentiated product:

- Workflow recipes: 5 cross-profession chains (workflows.json) that pass each
  output forward — slash commands (/ship-a-feature etc.), WORKFLOWS.md generated
  by scripts/build-workflows.mjs, README + MCP (list_workflows/get_workflow) wired
- Eval-backed quality: real per-skill scores from evals/results.json surfaced as
  badges in the playground and an honest README section (6 scored skills)
- One-click MCP: 'claude mcp add' install + workflow tools, works in any MCP client
- Playground: 'which skill?' recommender, with/without compare toggle, shareable
  ?skill= deep-links with prefilled inputs
- Sample-output gallery: hand-written examples for the hero five + generator
  (scripts/build-samples.mjs) + web/examples.html
- Skill-of-the-week: scheduled workflow + script that composes X/LinkedIn posts
  and posts to an optional webhook

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:56:11 +01:00

148 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Generates web/skills.json from the canonical skills/ directory.
// No dependencies — run with: node web/build-skills.mjs
import { readFileSync, readdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const skillsDir = join(root, 'skills');
const pluginsDir = join(root, 'plugins');
// --- Skill tiers (single source: skill-tiers.json) ---
// Anything not listed is 'stable'. Used to badge/filter skills in the playground.
let TIERS = { productionReady: [], experimental: [] };
const tiersFile = join(root, 'skill-tiers.json');
if (existsSync(tiersFile)) TIERS = { ...TIERS, ...JSON.parse(readFileSync(tiersFile, 'utf8')) };
const productionSet = new Set(TIERS.productionReady);
const experimentalSet = new Set(TIERS.experimental);
const tierFor = (name) =>
productionSet.has(name) ? 'production' : experimentalSet.has(name) ? 'experimental' : 'stable';
// --- Eval scores (from evals/results.json) ---
// Average the per-model "overall" (a 05 rubric score) for each scored skill.
// Only skills with eval cases get a score; the rest stay unscored (honest).
const evalScores = {};
const evalsFile = join(root, 'evals', 'results.json');
if (existsSync(evalsFile)) {
try {
const acc = {};
for (const r of JSON.parse(readFileSync(evalsFile, 'utf8')).results || []) {
(acc[r.skill] ||= []).push(r.overall);
}
for (const [skill, arr] of Object.entries(acc)) {
evalScores[skill] = {
score: Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 10) / 10,
runs: arr.length,
};
}
} catch { /* leave unscored on parse error */ }
}
// --- Map each skill name -> plugin bundle (for grouping/filtering) ---
const skillToPlugin = {};
if (existsSync(pluginsDir)) {
for (const plugin of readdirSync(pluginsDir)) {
const pSkills = join(pluginsDir, plugin, 'skills');
if (existsSync(pSkills) && statSync(pSkills).isDirectory()) {
for (const s of readdirSync(pSkills)) skillToPlugin[s] = plugin;
}
}
}
// --- Parse YAML-ish frontmatter (name + description only) ---
function parseFrontmatter(text) {
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!m) return { meta: {}, body: text };
const meta = {};
for (const line of m[1].split('\n')) {
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (kv) {
let v = kv[2].trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
meta[kv[1]] = v;
}
}
return { meta, body: m[2] };
}
// --- Extract input fields from the "Required Inputs" style section ---
function parseInputs(body) {
const lines = body.split('\n');
const headingRe = /^#{2,3}\s+.*(required inputs|inputs needed|information needed|what (i|you).*need)/i;
let i = lines.findIndex((l) => headingRe.test(l));
if (i === -1) return [];
const inputs = [];
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j];
if (/^#{1,3}\s/.test(line)) break; // next section
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
if (!bullet) continue;
const content = bullet[1];
const boldMatch = content.match(/\*\*(.+?)\*\*/);
if (!boldMatch) continue;
let label = boldMatch[1].replace(/\s*\/\s*/g, ' / ').trim();
// hint = remainder after the first bold label
let rest = content.replace(/\*\*(.+?)\*\*/, '').replace(/^[\s—:-]+/, '').trim();
rest = rest.replace(/\*\*/g, '').replace(/^\((.*)\)$/, '$1').trim();
const optional = /optional/i.test(content);
const long = /notes|description|summary|data|what happened|details|paste|context/i.test(
label + ' ' + rest
);
inputs.push({ label, hint: rest, optional, long });
}
return inputs;
}
// First sentence of the description, trimmed — used as the tile one-liner.
function summarize(desc) {
if (!desc) return '';
let first = desc.split(/(?<=\.)\s+/)[0].trim();
// If the first sentence is just a trigger ("Use when…"), fall back to the whole thing.
if (/^use\b/i.test(first) && desc.length > first.length) first = desc;
first = first.replace(/\s+/g, ' ').trim();
if (first.length > 150) first = first.slice(0, 147).replace(/[\s,;:]+\S*$/, '') + '…';
return first;
}
function titleFromName(name) {
return name
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
const skills = [];
for (const name of readdirSync(skillsDir)) {
const file = join(skillsDir, name, 'SKILL.md');
if (!existsSync(file)) continue;
const text = readFileSync(file, 'utf8');
const { meta, body } = parseFrontmatter(text);
const titleHeading = body.match(/^#\s+(.+)$/m);
skills.push({
name: meta.name || name,
title: (titleHeading ? titleHeading[1] : titleFromName(meta.name || name)).replace(/\s+Skill$/i, ''),
description: meta.description || '',
summary: summarize(meta.description || ''),
plugin: skillToPlugin[name] || 'other',
tier: tierFor(name),
eval: evalScores[meta.name || name] || null,
inputs: parseInputs(body),
instructions: body.trim(),
});
}
skills.sort((a, b) => a.title.localeCompare(b.title));
// No wall-clock timestamp: the output must be deterministic so CI can verify it
// is in sync with the source skills (a timestamp would make every build differ).
const out = { count: skills.length, skills };
writeFileSync(join(__dirname, 'skills.json'), JSON.stringify(out));
const tierCounts = skills.reduce((a, s) => ((a[s.tier] = (a[s.tier] || 0) + 1), a), {});
console.log(
`Wrote web/skills.json — ${skills.length} skills, ${new Set(skills.map((s) => s.plugin)).size} bundles ` +
`(production: ${tierCounts.production || 0}, stable: ${tierCounts.stable || 0}, experimental: ${tierCounts.experimental || 0}).`
);