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>
This commit is contained in:
Mohit
2026-06-19 09:56:11 +01:00
parent 7f06f0a993
commit 54f76456ab
30 changed files with 1168 additions and 67 deletions
+192 -58
View File
@@ -15,6 +15,11 @@ const TIER_META = {
experimental: { label: 'Experimental', cls: 'tier-experimental', dot: '🟡' },
};
// Small "eval-verified" badge for skills scored by the eval harness.
function evalBadgeText(s) {
return s.eval ? `${s.eval.score}/5` : '';
}
init();
async function init() {
@@ -46,6 +51,10 @@ async function init() {
el('copyGemini').addEventListener('click', () => copyPrompt('gemini'));
el('copyClaude').addEventListener('click', () => copyPrompt('claude'));
// "Which skill do I need?" recommender + shareable links.
el('recommendInput').addEventListener('input', renderRecommendations);
el('shareBtn').addEventListener('click', shareSkill);
try {
const res = await fetch('skills.json');
const data = await res.json();
@@ -64,6 +73,84 @@ async function init() {
sel.appendChild(o);
}
renderGallery();
applyShareLink(); // open a skill (and prefill inputs) if the URL points to one
}
// ---------- Recommender: rank skills by a free-text task description ----------
function scoreSkill(s, terms) {
const name = s.name.toLowerCase();
const desc = (s.description || '').toLowerCase();
const hay = (name + ' ' + desc + ' ' + (s.instructions || '')).toLowerCase();
let score = 0;
for (const t of terms) {
if (name.includes(t)) score += 5;
if (desc.includes(t)) score += 3;
else if (hay.includes(t)) score += 1;
}
return score;
}
function renderRecommendations() {
const box = el('recommendResults');
const q = el('recommendInput').value.toLowerCase().trim();
if (q.length < 3) { box.hidden = true; box.innerHTML = ''; return; }
const terms = [...new Set(q.split(/\s+/).filter((t) => t.length > 2))];
const ranked = SKILLS.map((s) => ({ s, score: scoreSkill(s, terms) }))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || (b.s.eval?.score || 0) - (a.s.eval?.score || 0))
.slice(0, 4);
box.innerHTML = '';
if (!ranked.length) { box.hidden = false; box.innerHTML = '<span class="recommend-empty">No close match — try the search below.</span>'; return; }
const lead = document.createElement('span');
lead.className = 'recommend-lead';
lead.textContent = 'Top matches:';
box.appendChild(lead);
for (const { s } of ranked) {
const chip = document.createElement('button');
chip.className = 'recommend-chip';
chip.type = 'button';
chip.textContent = s.title + (s.eval ? `${s.eval.score}/5` : '');
chip.addEventListener('click', () => { el('recommendInput').value = ''; box.hidden = true; selectSkill(s); });
box.appendChild(chip);
}
box.hidden = false;
}
// ---------- Shareable links: ?skill=<name>&i=<base64 inputs> ----------
function shareSkill() {
if (!current) return;
const fields = [...el('inputForm').querySelectorAll('input, textarea')];
const values = fields.map((f) => f.value);
const url = new URL(location.href.split('?')[0]);
url.searchParams.set('skill', current.name);
if (values.some((v) => v.trim())) {
try {
const packed = btoa(unescape(encodeURIComponent(JSON.stringify(values))));
if (packed.length < 1800) url.searchParams.set('i', packed); // keep links sane
} catch (_) { /* skip inputs if they don't encode */ }
}
const link = url.toString();
navigator.clipboard.writeText(link).then(
() => { el('shareMsg').textContent = 'Link copied — anyone who opens it lands on this skill, prefilled.'; },
() => { el('shareMsg').textContent = link; }
);
}
function applyShareLink() {
const params = new URLSearchParams(location.search);
const name = params.get('skill');
if (!name) return;
const s = SKILLS.find((x) => x.name === name);
if (!s) return;
selectSkill(s);
const packed = params.get('i');
if (packed) {
try {
const values = JSON.parse(decodeURIComponent(escape(atob(packed))));
const fields = [...el('inputForm').querySelectorAll('input, textarea')];
fields.forEach((f, i) => { if (values[i] != null) f.value = values[i]; });
} catch (_) { /* ignore malformed input payloads */ }
}
}
// ---------- Gallery (tiles) ----------
@@ -94,9 +181,16 @@ function renderGallery() {
const card = document.createElement('button');
card.className = 'skill-card';
card.innerHTML =
`<div class="card-tags"><span class="card-bundle"></span><span class="card-tier"></span></div>` +
`<div class="card-tags"><span class="card-bundle"></span><span class="card-eval"></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 evalEl = card.querySelector('.card-eval');
if (s.eval) {
evalEl.textContent = evalBadgeText(s);
evalEl.title = `Eval-scored ${s.eval.score}/5 across ${s.eval.runs} model runs`;
} else {
evalEl.remove();
}
const tierEl = card.querySelector('.card-tier');
tierEl.textContent = `${meta.dot} ${meta.label}`;
tierEl.classList.add(meta.cls);
@@ -127,12 +221,24 @@ function selectSkill(s) {
const tierTag = el('skillTier');
tierTag.textContent = `${meta.dot} ${meta.label}`;
tierTag.className = 'tier-tag ' + meta.cls;
const evalTag = el('skillEval');
if (s.eval) {
evalTag.textContent = `✅ Eval-scored ${s.eval.score}/5`;
evalTag.title = `Scored ${s.eval.score}/5 by an LLM judge across ${s.eval.runs} model runs`;
evalTag.hidden = false;
} else {
evalTag.hidden = true;
}
el('skillTitle').textContent = s.title;
el('skillDesc').textContent = s.description;
el('elsewhere').open = false;
el('copyMsg').textContent = '';
el('shareMsg').textContent = '';
el('outputWrap').hidden = true;
el('output').innerHTML = '';
el('output').hidden = false;
el('compareGrid').hidden = true;
el('compareGrid').innerHTML = '';
setStatus('');
window.scrollTo({ top: 0 });
@@ -160,6 +266,58 @@ function makeField(inp, i) {
return wrap;
}
// Stream one completion from the API into a target node. Returns the full text.
async function streamCompletion({ key, model, system, userMessage, node, signal }) {
const res = await fetch(API_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': key,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model,
max_tokens: 8192,
stream: true,
...(system ? { system } : {}),
messages: [{ role: 'user', content: userMessage }],
}),
signal,
});
if (!res.ok) throw new Error(parseApiError(await res.text(), res.status));
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let acc = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
let evt;
try { evt = JSON.parse(payload); } catch (_) { continue; }
if (evt.type === 'content_block_delta' && evt.delta && evt.delta.text) {
acc += evt.delta.text;
renderMarkdown(node, acc, true);
} else if (evt.type === 'error') {
throw new Error(evt.error ? evt.error.message : 'Stream error from the API.');
}
}
}
renderMarkdown(node, acc, false);
return acc;
}
const SKILL_SUFFIX =
'\n\n---\nThe user has provided their inputs below. Execute this skill now and produce the complete output. Do not ask follow-up questions — work with what is given and note any reasonable assumptions.';
// ---------- Run ----------
async function run() {
const key = el('apiKey').value.trim();
@@ -171,74 +329,50 @@ async function run() {
if (missing.length) return setStatus(`Fill in: ${missing.map((f) => f.dataset.label).join(', ')}`, true);
const userMessage = buildUserMessage(fields);
const system = current.instructions +
'\n\n---\nThe user has provided their inputs below. Execute this skill now and produce the complete output. Do not ask follow-up questions — work with what is given and note any reasonable assumptions.';
const system = current.instructions + SKILL_SUFFIX;
const model = el('model').value;
const compare = el('compareToggle').checked;
const out = el('output');
out.innerHTML = '';
out.dataset.raw = '';
el('outputWrap').hidden = false;
el('runBtn').disabled = true;
el('stopBtn').hidden = false;
setStatus('Running…');
controller = new AbortController();
// Single mode → #output. Compare mode → two panes in #compareGrid.
const out = el('output');
const grid = el('compareGrid');
let withNode, plainNode;
if (compare) {
out.hidden = true;
grid.hidden = false;
grid.innerHTML =
'<div class="compare-pane"><div class="compare-label">✨ With the skill</div><article class="output markdown" id="paneWith"></article></div>' +
'<div class="compare-pane"><div class="compare-label">📄 Plain prompt (no skill)</div><article class="output markdown" id="panePlain"></article></div>';
withNode = el('paneWith');
plainNode = el('panePlain');
} else {
grid.hidden = true;
out.hidden = false;
out.innerHTML = '';
out.dataset.raw = '';
}
let acc = '';
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': key,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: el('model').value,
max_tokens: 8192,
stream: true,
system,
messages: [{ role: 'user', content: userMessage }],
}),
signal: controller.signal,
});
if (!res.ok) throw new Error(parseApiError(await res.text(), res.status));
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
let evt;
try {
evt = JSON.parse(payload);
} catch (_) {
continue; // skip an unparseable / partial SSE line
}
if (evt.type === 'content_block_delta' && evt.delta && evt.delta.text) {
acc += evt.delta.text;
renderMarkdown(out, acc, true);
} else if (evt.type === 'error') {
throw new Error(evt.error ? evt.error.message : 'Stream error from the API.');
}
}
setStatus(compare ? 'Running both…' : 'Running…');
if (compare) {
// Plain = the same inputs with no skill system prompt.
[acc] = await Promise.all([
streamCompletion({ key, model, system, userMessage, node: withNode, signal: controller.signal }),
streamCompletion({ key, model, system: '', userMessage, node: plainNode, signal: controller.signal }),
]);
} else {
acc = await streamCompletion({ key, model, system, userMessage, node: out, signal: controller.signal });
}
renderMarkdown(out, acc, false);
out.dataset.raw = acc;
out.dataset.raw = acc; // copy/download use the skill output, in either mode
setStatus('Done.');
} catch (e) {
if (e.name === 'AbortError') {
out.dataset.raw = acc;
renderMarkdown(out, acc, false);
setStatus('Stopped.');
} else {
setStatus(e.message || 'Request failed.', true);
+21
View File
@@ -20,6 +20,26 @@ 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)) {
@@ -109,6 +129,7 @@ for (const name of readdirSync(skillsDir)) {
summary: summarize(meta.description || ''),
plugin: skillToPlugin[name] || 'other',
tier: tierFor(name),
eval: evalScores[meta.name || name] || null,
inputs: parseInputs(body),
instructions: body.trim(),
});
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sample Outputs — see what the skills produce</title>
<meta name="description" content="Real example outputs from the professional skill library — see exactly what each skill produces before you run it." />
<link rel="stylesheet" href="styles.css" />
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">
<img src="assets/product-notes.jpg" alt="Product Notes" class="brand-logo" />
<div class="brand-text">
<h1>Sample Outputs</h1>
<p class="tagline">See exactly what each skill produces — before you run anything.</p>
</div>
</div>
</header>
<div class="key-note">
📄 These are real outputs from the skills. Like one? <a href="index.html">Run it yourself in the Playground →</a>
· 📚 <a href="catalog.html">Catalog</a> · 🏆 <a href="leaderboard.html">Leaderboard</a>
</div>
<main class="main">
<div class="recommend" style="margin-bottom:18px">
<input id="filter" type="search" placeholder="Filter samples…" />
</div>
<section id="samples"></section>
</main>
<script>
const elx = (id) => document.getElementById(id);
let SAMPLES = [];
async function init() {
try {
SAMPLES = (await (await fetch('samples.json')).json()).samples;
} catch (e) {
elx('samples').innerHTML = '<p class="empty-msg">Could not load samples.json. Run <code>node scripts/build-samples.mjs</code>.</p>';
return;
}
elx('filter').addEventListener('input', render);
render();
}
function render() {
const q = elx('filter').value.toLowerCase().trim();
const box = elx('samples');
box.innerHTML = '';
const list = SAMPLES.filter((s) => !q || (s.title + ' ' + s.skill + ' ' + s.input).toLowerCase().includes(q));
if (!list.length) { box.innerHTML = '<p class="empty-msg">No samples match.</p>'; return; }
for (const s of list) {
const card = document.createElement('details');
card.className = 'sample-card';
card.open = list.length <= 2;
const note = s.source ? `<span class="sample-source">${s.source}</span>` : '';
card.innerHTML =
`<summary><span class="sample-title">${s.title}</span> ${note}` +
`<a class="sample-run" href="index.html?skill=${encodeURIComponent(s.skill)}" onclick="event.stopPropagation()">Run this →</a></summary>` +
`<p class="sample-input"><strong>Input:</strong> ${escapeHtml(s.input)}</p>` +
`<div class="output markdown sample-output"></div>`;
card.querySelector('.sample-output').innerHTML = DOMPurify.sanitize(marked.parse(s.output));
box.appendChild(card);
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
init();
</script>
</body>
</html>
+11 -1
View File
@@ -34,7 +34,12 @@
<div class="key-note">
🔒 Your key is stored only in this browser and sent directly to api.anthropic.com — never to us.
Get one at <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener">console.anthropic.com</a>.
· 📚 <a href="catalog.html">Catalog</a> · 🏆 <a href="leaderboard.html">Leaderboard</a>
· 📄 <a href="examples.html">Sample outputs</a> · 📚 <a href="catalog.html">Catalog</a> · 🏆 <a href="leaderboard.html">Leaderboard</a>
</div>
<div class="recommend" id="recommend">
<input id="recommendInput" type="text" placeholder="🧭 Not sure which skill? Describe your task — e.g. “explain a metric drop to my CEO”" />
<div id="recommendResults" class="recommend-results" hidden></div>
</div>
<div class="controls" id="controls">
@@ -60,8 +65,11 @@
<div class="skill-head">
<span class="bundle-tag" id="skillBundle"></span>
<span class="tier-tag" id="skillTier"></span>
<span class="eval-badge" id="skillEval" hidden></span>
<h2 id="skillTitle"></h2>
<p id="skillDesc" class="skill-desc"></p>
<button id="shareBtn" class="ghost share-btn" type="button" title="Copy a link that opens this skill with these inputs">🔗 Share</button>
<span id="shareMsg" class="copy-msg"></span>
</div>
<details class="elsewhere" id="elsewhere">
@@ -80,6 +88,7 @@
<div class="actions">
<button id="runBtn" class="primary" type="button">Run with my Claude key</button>
<button id="stopBtn" class="ghost" type="button" hidden>Stop</button>
<label class="compare-toggle" title="Run the same inputs with and without the skill, side by side"><input type="checkbox" id="compareToggle" /> ⚖️ Compare vs. plain prompt</label>
<span id="status" class="status"></span>
</div>
@@ -92,6 +101,7 @@
</div>
</div>
<article id="output" class="output markdown"></article>
<div id="compareGrid" class="compare-grid" hidden></div>
</div>
</section>
</main>
+1
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
{
"week": 2946,
"skill": "retention-analysis",
"title": "Retention Analysis",
"summary": "Structure a retention analysis, churn investigation, or engagement deep-dive for any product team.",
"link": "https://mohitagw15856.github.io/pm-claude-skills/index.html?skill=retention-analysis",
"generatedAt": "2026-06-19T08:52:42.226Z",
"posts": {
"x": "🛠️ Skill of the week: Retention Analysis\n\nStructure a retention analysis, churn investigation, or engagement deep-dive for any product team.\n\nRun it free in your browser (no install) 👇\nhttps://mohitagw15856.github.io/pm-claude-skills/index.html?skill=retention-analysis\n\n#ClaudeAI #AItools #ProductManagement",
"linkedin": "🛠️ Skill of the week: Retention Analysis\n\nStructure a retention analysis, churn investigation, or engagement deep-dive for any product team.\n\nIt's one of 174 open-source AI skills that make Claude, ChatGPT, and Gemini produce real professional work. Try this one free in the browser — no install:\nhttps://mohitagw15856.github.io/pm-claude-skills/index.html?skill=retention-analysis\n\n⭐ https://github.com/mohitagw15856/pm-claude-skills"
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+57
View File
@@ -187,8 +187,65 @@ button.ghost:hover { border-color: var(--accent); }
.cursor::after { content: "▍"; color: var(--accent); animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0; } }
/* ---------- Recommender ---------- */
.recommend { max-width: 1100px; margin: 18px auto 0; padding: 0 4px; }
.recommend > input {
width: 100%; box-sizing: border-box; padding: 12px 14px; font-size: 14px;
background: var(--panel); color: var(--text);
border: 1px solid var(--border); border-radius: 10px;
}
.recommend > input:focus { outline: none; border-color: var(--accent); }
.recommend-results {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px;
}
.recommend-lead { font-size: 12.5px; color: var(--muted); }
.recommend-empty { font-size: 12.5px; color: var(--muted); }
.recommend-chip {
font-size: 12.5px; padding: 6px 11px; border-radius: 99px; cursor: pointer;
background: rgba(217,119,87,.12); color: var(--accent-2);
border: 1px solid rgba(217,119,87,.35);
}
.recommend-chip:hover { background: rgba(217,119,87,.22); }
/* ---------- Eval badges ---------- */
.card-eval, .eval-badge {
font-size: 10px; font-weight: 700; letter-spacing: .02em; padding: 2px 7px;
border-radius: 99px; white-space: nowrap;
color: #6ee7b7; background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.35);
}
.eval-badge { display: inline-block; font-size: 11px; margin: 0 0 8px 6px; }
/* ---------- Share button ---------- */
.share-btn { margin-top: 10px; }
/* ---------- Sample-output gallery ---------- */
.sample-card {
max-width: 880px; margin: 0 auto 14px; background: var(--panel);
border: 1px solid var(--border); border-radius: 12px; padding: 4px 18px;
}
.sample-card summary {
cursor: pointer; padding: 12px 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.sample-title { font-size: 15px; font-weight: 600; }
.sample-source { font-size: 11px; color: var(--muted); }
.sample-run { margin-left: auto; font-size: 12.5px; color: var(--accent-2); text-decoration: none; }
.sample-run:hover { text-decoration: underline; }
.sample-input { font-size: 12.5px; color: var(--muted); line-height: 1.5; margin: 0 0 8px; }
.sample-output { border-top: 1px solid var(--border); padding-top: 12px; }
/* ---------- Compare mode ---------- */
.compare-toggle { font-size: 12.5px; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.compare-pane { min-width: 0; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.compare-label {
font-size: 12px; font-weight: 600; padding: 8px 12px;
background: var(--panel-2); border-bottom: 1px solid var(--border);
}
.compare-pane .output { padding: 12px 14px; }
@media (max-width: 620px) {
.key-field input { width: 200px; }
.brand-text h1 { font-size: 15px; }
.gallery { grid-template-columns: 1fr; }
.compare-grid { grid-template-columns: 1fr; }
}