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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}).`
|
||||
);
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user