Fix skills.json determinism (CI blocker) and upgrade the playground

The check-generated CI step was failing with "web/skills.json is stale"
because build-skills.mjs stamped a wall-clock generatedAt into the file, so
every rebuild differed and git diff --exit-code never matched.

- web/build-skills.mjs: drop the unused generatedAt timestamp -> deterministic
  output the CI staleness check can verify. Also tags each skill with its tier.
- skill-tiers.json: single machine-readable source for tier membership
  (Production-Ready / Experimental); TIERS.md points to it.

Playground upgrades (hosted on GitHub Pages):
- Tier filter (Production-Ready / Stable / Experimental) + per-tile tier badges.
- "Use this skill in another tool" panel: copy the instructions formatted for
  ChatGPT, Gemini, or raw — mirrors the generated exports/ files.
- web/README documents the new options and the deterministic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JWn5jRD5tcEFKrubjQ6Px
This commit is contained in:
Claude
2026-06-17 08:16:46 +00:00
parent 46f5d939de
commit 69d4fab0b3
9 changed files with 158 additions and 5 deletions
+9
View File
@@ -33,6 +33,15 @@ each new wave of skills bumps the **major** version, extensions and fixes bump
strongest work.
- **Cross-tool compatibility** — README now documents which platforms the skills work
on (Claude Code natively; the SKILL.md bodies port to other agents and chat LLMs).
- **Skill Playground upgrades** — the hosted web app gains a **tier filter** and per-tile
tier badges, plus a *"Use this skill in another tool"* panel that copies the
instructions formatted for ChatGPT, Gemini, or raw. Tier data comes from a single
machine-readable source, `skill-tiers.json`.
### Fixed
- **`web/skills.json` is now deterministic.** Removed the wall-clock `generatedAt` field
(it was unused by the UI and made every rebuild differ), so the new `check-generated`
CI step can reliably verify the index is in sync with the source skills.
- **Related Projects** — README section linking to other community Claude Skills
libraries and the `awesome-claude-skills` / `awesome-claude-code` lists.
+4
View File
@@ -81,3 +81,7 @@ list in the [README](README.md#-all-167-skills).
once they have a stable output format and real-world use — see
[SKILL-AUTHORING-STANDARD.md](SKILL-AUTHORING-STANDARD.md#7-tiering). Think a skill is
mis-tiered? [Open an issue](../../issues).*
> **For tooling:** the machine-readable tier membership lives in
> [`skill-tiers.json`](skill-tiers.json) (the Skill Playground reads it to badge and
> filter skills). Keep this page and that file in sync when re-tiering.
+19
View File
@@ -0,0 +1,19 @@
{
"_comment": "Machine-readable source for skill tiers. Keep in sync with TIERS.md. Any skill not listed here is 'stable'. Consumed by web/build-skills.mjs to tag skills.json.",
"productionReady": [
"prd-template", "meeting-notes", "stakeholder-update", "user-research-synthesis", "competitive-analysis",
"rice-prioritisation", "feature-prioritisation", "okr-builder", "roadmap-narrative", "rice-impact-matrix",
"sprint-planning", "sprint-brief", "user-story-writer", "retro-analysis", "ab-test-planner", "product-launch-checklist", "technical-spec-template",
"customer-journey-map", "assumption-mapper", "user-interview-synthesis", "discovery-interview-guide", "job-story-mapper",
"data-analysis-standard", "retention-analysis", "cohort-analysis", "metrics-framework", "product-health-analysis",
"cs-health-scorecard", "churn-analysis", "qbr-deck", "renewal-playbook", "customer-success-plan", "cs-escalation-brief",
"code-review-checklist", "incident-postmortem", "architecture-decision-record", "api-docs-writer", "runbook-writer", "changelog-generator", "pr-description-writer", "technical-debt-register",
"go-to-market", "competitor-teardown", "product-positioning-doc",
"executive-summary", "press-release"
],
"experimental": [
"instagram-post-downloader", "substack-notes-scraper", "thumbnail-creator", "notebooklm-connector",
"email-triage", "morning-intelligence", "last-30-days-research", "competitor-signal-tracker",
"multi-source-signal-synthesiser"
]
}
+12 -1
View File
@@ -5,6 +5,15 @@ Pick a skill → it becomes a form → fill it in → Claude executes the skill'
and streams the result. Your key is stored only in your browser (`localStorage`) and sent
directly to `api.anthropic.com`. Nothing touches a server we own.
## What you can do
- **Search and filter** the full library by keyword, **bundle**, and **maturity tier**
(🟢 Production-Ready · 🔵 Stable · 🟡 Experimental) — every tile shows its tier.
- **Run a skill** against the Claude API and stream the output (copy or download as `.md`).
- **Use it in another tool** — each skill has a *"Use this skill in another tool"* panel
that copies the instructions formatted for **ChatGPT**, **Gemini**, or as raw text, so
you can paste it into any assistant. (Same output as the generated `exports/` files.)
## Run locally
```bash
@@ -20,7 +29,9 @@ Paste a key from [console.anthropic.com](https://console.anthropic.com/settings/
## How it works
- `build-skills.mjs` scans `../skills/*/SKILL.md`, parses the frontmatter and the
**Required Inputs** section, and writes `skills.json` (the UI's data source).
**Required Inputs** section, tags each skill with its tier (from `../skill-tiers.json`),
and writes a **deterministic** `skills.json` (the UI's data source — no timestamp, so CI
can verify it stays in sync).
- `app.js` sends the skill's instruction body as the `system` prompt and the filled-in
fields as the user message, using the Anthropic Messages API with
`anthropic-dangerous-direct-browser-access: true` for direct browser calls.
+49 -1
View File
@@ -9,6 +9,12 @@ let SKILLS = [];
let current = null;
let controller = null;
const TIER_META = {
production: { label: 'Production-Ready', cls: 'tier-production', dot: '🟢' },
stable: { label: 'Stable', cls: 'tier-stable', dot: '🔵' },
experimental: { label: 'Experimental', cls: 'tier-experimental', dot: '🟡' },
};
init();
async function init() {
@@ -28,12 +34,18 @@ async function init() {
el('search').addEventListener('input', renderGallery);
el('pluginFilter').addEventListener('change', renderGallery);
el('tierFilter').addEventListener('change', renderGallery);
el('backBtn').addEventListener('click', showGallery);
el('runBtn').addEventListener('click', run);
el('stopBtn').addEventListener('click', () => controller && controller.abort());
el('copyBtn').addEventListener('click', () => navigator.clipboard.writeText(el('output').dataset.raw || ''));
el('downloadBtn').addEventListener('click', downloadOutput);
// Copy the skill's instructions formatted for another assistant.
el('copyChatgpt').addEventListener('click', () => copyPrompt('chatgpt'));
el('copyGemini').addEventListener('click', () => copyPrompt('gemini'));
el('copyClaude').addEventListener('click', () => copyPrompt('claude'));
try {
const res = await fetch('skills.json');
const data = await res.json();
@@ -58,11 +70,13 @@ async function init() {
function renderGallery() {
const q = el('search').value.toLowerCase().trim();
const bundle = el('pluginFilter').value;
const tier = el('tierFilter').value;
const gallery = el('gallery');
gallery.innerHTML = '';
const matches = SKILLS.filter((s) => {
if (bundle && s.plugin !== bundle) return false;
if (tier && (s.tier || 'stable') !== tier) return false;
if (!q) return true;
return (s.title + ' ' + s.description + ' ' + s.name).toLowerCase().includes(q);
});
@@ -76,11 +90,16 @@ function renderGallery() {
const frag = document.createDocumentFragment();
for (const s of matches) {
const meta = TIER_META[s.tier] || TIER_META.stable;
const card = document.createElement('button');
card.className = 'skill-card';
card.innerHTML =
`<span class="card-bundle"></span><h3 class="card-title"></h3><p class="card-summary"></p>`;
`<div class="card-tags"><span class="card-bundle"></span><span class="card-tier"></span></div>` +
`<h3 class="card-title"></h3><p class="card-summary"></p>`;
card.querySelector('.card-bundle').textContent = s.plugin;
const tierEl = card.querySelector('.card-tier');
tierEl.textContent = `${meta.dot} ${meta.label}`;
tierEl.classList.add(meta.cls);
card.querySelector('.card-title').textContent = s.title;
card.querySelector('.card-summary').textContent = s.summary || s.description;
card.addEventListener('click', () => selectSkill(s));
@@ -104,8 +123,14 @@ function selectSkill(s) {
el('controls').hidden = true;
el('runner').hidden = false;
el('skillBundle').textContent = s.plugin;
const meta = TIER_META[s.tier] || TIER_META.stable;
const tierTag = el('skillTier');
tierTag.textContent = `${meta.dot} ${meta.label}`;
tierTag.className = 'tier-tag ' + meta.cls;
el('skillTitle').textContent = s.title;
el('skillDesc').textContent = s.description;
el('elsewhere').open = false;
el('copyMsg').textContent = '';
el('outputWrap').hidden = true;
el('output').innerHTML = '';
setStatus('');
@@ -225,6 +250,29 @@ async function run() {
}
}
// Format the current skill's instructions for another assistant, mirroring the
// per-platform renderers in scripts/build-exports.mjs.
function promptFor(platform) {
if (!current) return '';
const body = current.instructions;
if (platform === 'gemini') {
return `You are a specialised assistant. ${current.description}\n\nFollow these instructions:\n\n${body}`;
}
return body; // chatgpt + claude use the body directly
}
async function copyPrompt(platform) {
const text = promptFor(platform);
if (!text) return;
const labels = { chatgpt: 'ChatGPT', gemini: 'Gemini', claude: 'raw' };
try {
await navigator.clipboard.writeText(text);
el('copyMsg').textContent = `Copied ${labels[platform]} prompt — paste it into the tool's instructions.`;
} catch (_) {
el('copyMsg').textContent = 'Copy failed — your browser blocked clipboard access.';
}
}
function buildUserMessage(fields) {
return fields
.filter((f) => f.value.trim())
+19 -2
View File
@@ -10,6 +10,16 @@ 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';
// --- Map each skill name -> plugin bundle (for grouping/filtering) ---
const skillToPlugin = {};
if (existsSync(pluginsDir)) {
@@ -98,12 +108,19 @@ for (const name of readdirSync(skillsDir)) {
description: meta.description || '',
summary: summarize(meta.description || ''),
plugin: skillToPlugin[name] || 'other',
tier: tierFor(name),
inputs: parseInputs(body),
instructions: body.trim(),
});
}
skills.sort((a, b) => a.title.localeCompare(b.title));
const out = { generatedAt: new Date().toISOString(), count: skills.length, skills };
// 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));
console.log(`Wrote web/skills.json — ${skills.length} skills, ${Object.keys(skillToPlugin).length ? new Set(skills.map(s=>s.plugin)).size : 0} bundles.`);
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}).`
);
+18
View File
@@ -39,6 +39,12 @@
<div class="controls" id="controls">
<input id="search" type="search" placeholder="Search skills…" />
<select id="pluginFilter"><option value="">All bundles</option></select>
<select id="tierFilter" title="Maturity tier">
<option value="">All tiers</option>
<option value="production">🟢 Production-Ready</option>
<option value="stable">🔵 Stable</option>
<option value="experimental">🟡 Experimental</option>
</select>
<span id="count" class="count"></span>
</div>
@@ -52,10 +58,22 @@
<div class="skill-head">
<span class="bundle-tag" id="skillBundle"></span>
<span class="tier-tag" id="skillTier"></span>
<h2 id="skillTitle"></h2>
<p id="skillDesc" class="skill-desc"></p>
</div>
<details class="elsewhere" id="elsewhere">
<summary>Use this skill in another tool ↗</summary>
<p class="elsewhere-note">These skills aren't locked to Claude. Copy the ready-made instructions into another assistant — you keep the full framework, you just paste it once.</p>
<div class="elsewhere-actions">
<button id="copyChatgpt" class="ghost" type="button">Copy for ChatGPT</button>
<button id="copyGemini" class="ghost" type="button">Copy for Gemini</button>
<button id="copyClaude" class="ghost" type="button">Copy raw instructions</button>
<span id="copyMsg" class="copy-msg"></span>
</div>
</details>
<form id="inputForm" class="input-form"></form>
<div class="actions">
+1 -1
View File
File diff suppressed because one or more lines are too long
+27
View File
@@ -93,6 +93,17 @@ a { color: var(--accent-2); }
.card-summary { margin: 0; font-size: 12.5px; color: var(--muted); line-height: 1.5; }
.empty-msg { color: var(--muted); padding: 40px; text-align: center; grid-column: 1 / -1; }
/* ---------- Tier badges ---------- */
.card-tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.card-tags .card-bundle { margin: 0; }
.card-tier {
font-size: 10px; font-weight: 600; letter-spacing: .02em; padding: 2px 7px;
border-radius: 99px; border: 1px solid transparent; white-space: nowrap; margin-left: auto;
}
.tier-production { color: #6ee7b7; background: rgba(16,185,129,.12); border-color: rgba(16,185,129,.35); }
.tier-stable { color: #93c5fd; background: rgba(59,130,246,.12); border-color: rgba(59,130,246,.35); }
.tier-experimental { color: #fcd34d; background: rgba(245,158,11,.12); border-color: rgba(245,158,11,.35); }
/* ---------- Runner ---------- */
.runner { max-width: 880px; margin: 0 auto; }
.back {
@@ -108,6 +119,22 @@ a { color: var(--accent-2); }
}
.skill-head h2 { margin: 0 0 6px; font-size: 23px; }
.skill-desc { color: var(--muted); font-size: 13.5px; line-height: 1.55; margin: 0; }
.tier-tag {
display: inline-block; font-size: 11px; font-weight: 600; padding: 3px 9px;
border-radius: 99px; margin: 0 0 8px 6px; border: 1px solid transparent;
}
/* ---------- Use in another tool ---------- */
.elsewhere {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 4px 14px; margin-bottom: 4px;
}
.elsewhere summary {
cursor: pointer; font-size: 13px; font-weight: 600; padding: 9px 0; color: var(--accent-2);
}
.elsewhere-note { color: var(--muted); font-size: 12.5px; line-height: 1.5; margin: 4px 0 12px; }
.elsewhere-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding-bottom: 12px; }
.copy-msg { font-size: 12px; color: #6ee7b7; }
.input-form { display: flex; flex-direction: column; gap: 14px; margin: 22px 0; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; }