feat(web): add Skill Playground — browser UI to run any skill with your own key
A zero-backend static web app to run any of the 172 skills directly in the browser using the user's own Claude API key (stored only in localStorage, sent straight to api.anthropic.com). - build-skills.mjs: generates skills.json from skills/*/SKILL.md, parsing frontmatter, the Required Inputs section (-> form fields), and a one-line summary for each skill tile. - Tile gallery with bundle tag, title, and one-line description; search + bundle filter; click a tile to open an auto-generated input form. - Streams output via the Anthropic Messages API (direct browser access), with copy/download, model picker, and Show/Hide key toggle. - Product Notes logo in the header. - README: add Skill Playground section + screenshot, a table of contents, and collapse the long changelog and full skills list into <details> blocks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,18 @@ A community-built library of Claude Skills for professionals across every field
|
||||
**🆕 Latest release (v14.0.0):** 12 new community-inspired skills across 4 bundles — a brand new Writers & Content Creators profession (Instagram downloader, AEO optimizer, thumbnail creator, Substack scraper, notes humanizer), plus decision-making, productivity, and Claude Code power tools.
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [🚀 Quick Install](#-quick-install-2-minutes)
|
||||
- [🌐 Skill Playground — try any skill in your browser](#-skill-playground--try-any-skill-in-your-browser)
|
||||
- [📦 Plugin Directory](#-plugin-directory)
|
||||
- [🤖 Building Blocks for Agent Templates](#-building-blocks-for-agent-templates)
|
||||
- [🗂️ All 167 Skills](#️-all-167-skills)
|
||||
- [📋 Changelog](#-changelog)
|
||||
- [🤝 Contributing](#-contributing--add-your-skill)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Install (2 minutes)
|
||||
|
||||
In Claude Code, run:
|
||||
@@ -62,6 +74,26 @@ ln -s ~/pm-claude-skills/skills/* ~/.claude/skills/
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Skill Playground — Try Any Skill in Your Browser
|
||||
|
||||
Don't want to install anything yet? Run any of these skills from a **zero-backend web app** using **your own Claude API key**. Pick a skill, fill in the auto-generated form, and Claude streams the result. Your key is stored only in your browser (`localStorage`) and sent directly to the Anthropic API — nothing touches a server we own.
|
||||
|
||||

|
||||
|
||||
**Run it locally:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mohitagw15856/pm-claude-skills.git
|
||||
cd pm-claude-skills
|
||||
node web/build-skills.mjs # generate the skill index (skills.json)
|
||||
cd web && python3 -m http.server 8000 # serve over HTTP (not file://)
|
||||
# open http://localhost:8000 and paste a key from console.anthropic.com
|
||||
```
|
||||
|
||||
It's fully static — deploy the `web/` folder to GitHub Pages, Netlify, or Vercel with no environment variables. Full details in [`web/README.md`](web/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 📦 Plugin Directory
|
||||
|
||||
Not sure which plugin to install? Here's what each one covers:
|
||||
@@ -189,7 +221,12 @@ More templates will follow. If you want to contribute one, see the [template con
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New in v14.0.0 — Writers & Content Creators + 7 Community Skills
|
||||
## 📋 Changelog
|
||||
|
||||
<details>
|
||||
<summary><strong>Release history — v6.0.0 → v14.0.0</strong> (click to expand)</summary>
|
||||
|
||||
### 🆕 What's New in v14.0.0 — Writers & Content Creators + 7 Community Skills
|
||||
|
||||
**12 new community-inspired skills across 4 bundles:**
|
||||
|
||||
@@ -229,7 +266,7 @@ The library now includes **167 skills** across **18 professions** + 4 working ag
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New in v13.0.0 — Social Media Profession
|
||||
### 🆕 What's New in v13.0.0 — Social Media Profession
|
||||
|
||||
**5 new skills — a complete Social Media profession bundle:**
|
||||
|
||||
@@ -250,7 +287,7 @@ claude plugin install pm-social@pm-claude-skills
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New in v12.0.0 — 150 Skills Milestone
|
||||
### 🆕 What's New in v12.0.0 — 150 Skills Milestone
|
||||
|
||||
**15 new skills across 10 bundles:**
|
||||
|
||||
@@ -276,7 +313,7 @@ The library now includes **150 skills** across **16 professions** + 4 working ag
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New in v10.0.0
|
||||
### 🆕 What's New in v10.0.0
|
||||
|
||||
**Two star milestones unlocked — 8 new skills shipped:**
|
||||
|
||||
@@ -316,7 +353,7 @@ The `pm-engineering` bundle now has **10 skills** — the most complete engineer
|
||||
|
||||
---
|
||||
|
||||
## 📖 v6.0.0 — 100 Skills Milestone
|
||||
### 📖 v6.0.0 — 100 Skills Milestone
|
||||
|
||||
**7 skills added:**
|
||||
|
||||
@@ -330,6 +367,8 @@ The `pm-engineering` bundle now has **10 skills** — the most complete engineer
|
||||
| **Sales Forecasting Model** | pm-sales | Pipeline-based forecast with stage model, scenario analysis, assumption log, and activity sanity check |
|
||||
| **Tax Planning Checklist** | pm-finance | Year-end tax planning review framework across income, pension, CGT, business reliefs, and ISAs |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📚 The Article Series
|
||||
@@ -359,6 +398,11 @@ This repo was built alongside a published article series. Read the full story:
|
||||
|
||||
## 🗂️ All 167 Skills
|
||||
|
||||
The [Plugin Directory](#-plugin-directory) above summarises every bundle. Expand below for the full per-skill breakdown with folder paths.
|
||||
|
||||
<details>
|
||||
<summary><strong>Browse all 167 skills by profession</strong> (click to expand)</summary>
|
||||
|
||||
### 🛠️ Product Management (Skills 1–37)
|
||||
**Bundles:** `pm-essentials` · `pm-discovery` · `pm-planning` · `pm-delivery` · `pm-analytics` · `pm-strategy` · `pm-advanced` · `pm-rituals`
|
||||
|
||||
@@ -674,6 +718,8 @@ claude plugin install pm-writers@pm-claude-skills
|
||||
| 159 | **Substack Notes Scraper** 🆕 | `skills/substack-notes-scraper/` | Scrapes Substack Notes and exports likes, comments, and restacks to a formatted .xlsx with frozen headers, filters, and top-performer highlighting |
|
||||
| 160 | **Notes Humanizer** 🆕 | `skills/notes-humanizer/` | Strips AI writing patterns (em dashes, filler phrases, uniform rhythm) across 3 phases: audit, strip, inject — returns side-by-side comparison and clean final text |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ❤️ Sponsor This Work
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Skill Playground
|
||||
|
||||
A zero-backend web app to run any skill in this repo with **your own Claude API key**.
|
||||
Pick a skill → it becomes a form → fill it in → Claude executes the skill's instructions
|
||||
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.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
node web/build-skills.mjs # regenerate skills.json from skills/
|
||||
cd web && python3 -m http.server 8000
|
||||
# open http://localhost:8000
|
||||
```
|
||||
|
||||
> It must be served over HTTP (not opened as a `file://` URL) so `fetch('skills.json')` works.
|
||||
|
||||
Paste a key from [console.anthropic.com](https://console.anthropic.com/settings/keys) and run.
|
||||
|
||||
## 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).
|
||||
- `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.
|
||||
|
||||
## Keep it in sync
|
||||
|
||||
Re-run `node web/build-skills.mjs` whenever skills are added or edited, and commit the
|
||||
updated `skills.json`. (Or wire it into CI / a pre-commit hook.)
|
||||
|
||||
## Deploy
|
||||
|
||||
It's fully static — host the `web/` folder on GitHub Pages, Netlify, Vercel, or any
|
||||
static host. No environment variables, no server.
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
'use strict';
|
||||
|
||||
const KEY_STORE = 'anthropic_api_key';
|
||||
const MODEL_STORE = 'anthropic_model';
|
||||
const API_URL = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
let SKILLS = [];
|
||||
let current = null;
|
||||
let controller = null;
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
const savedKey = localStorage.getItem(KEY_STORE);
|
||||
if (savedKey) el('apiKey').value = savedKey;
|
||||
const savedModel = localStorage.getItem(MODEL_STORE);
|
||||
if (savedModel) el('model').value = savedModel;
|
||||
|
||||
el('apiKey').addEventListener('input', (e) => localStorage.setItem(KEY_STORE, e.target.value.trim()));
|
||||
el('model').addEventListener('change', (e) => localStorage.setItem(MODEL_STORE, e.target.value));
|
||||
el('keyToggle').addEventListener('click', () => {
|
||||
const f = el('apiKey');
|
||||
const show = f.type === 'password';
|
||||
f.type = show ? 'text' : 'password';
|
||||
el('keyToggle').textContent = show ? 'Hide' : 'Show';
|
||||
});
|
||||
|
||||
el('search').addEventListener('input', renderGallery);
|
||||
el('pluginFilter').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);
|
||||
|
||||
try {
|
||||
const res = await fetch('skills.json');
|
||||
const data = await res.json();
|
||||
SKILLS = data.skills;
|
||||
} catch (e) {
|
||||
el('gallery').innerHTML = '<p class="empty-msg">Could not load skills.json. Run <code>node web/build-skills.mjs</code> and serve this folder over HTTP.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const bundles = [...new Set(SKILLS.map((s) => s.plugin))].sort();
|
||||
const sel = el('pluginFilter');
|
||||
for (const b of bundles) {
|
||||
const o = document.createElement('option');
|
||||
o.value = b;
|
||||
o.textContent = b;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
renderGallery();
|
||||
}
|
||||
|
||||
// ---------- Gallery (tiles) ----------
|
||||
function renderGallery() {
|
||||
const q = el('search').value.toLowerCase().trim();
|
||||
const bundle = el('pluginFilter').value;
|
||||
const gallery = el('gallery');
|
||||
gallery.innerHTML = '';
|
||||
|
||||
const matches = SKILLS.filter((s) => {
|
||||
if (bundle && s.plugin !== bundle) return false;
|
||||
if (!q) return true;
|
||||
return (s.title + ' ' + s.description + ' ' + s.name).toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
el('count').textContent = `${matches.length} skill${matches.length === 1 ? '' : 's'}`;
|
||||
|
||||
if (!matches.length) {
|
||||
gallery.innerHTML = '<p class="empty-msg">No skills match your search.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const s of matches) {
|
||||
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>`;
|
||||
card.querySelector('.card-bundle').textContent = s.plugin;
|
||||
card.querySelector('.card-title').textContent = s.title;
|
||||
card.querySelector('.card-summary').textContent = s.summary || s.description;
|
||||
card.addEventListener('click', () => selectSkill(s));
|
||||
frag.appendChild(card);
|
||||
}
|
||||
gallery.appendChild(frag);
|
||||
}
|
||||
|
||||
function showGallery() {
|
||||
current = null;
|
||||
el('runner').hidden = true;
|
||||
el('gallery').hidden = false;
|
||||
el('controls').hidden = false;
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
// ---------- Select & build form ----------
|
||||
function selectSkill(s) {
|
||||
current = s;
|
||||
el('gallery').hidden = true;
|
||||
el('controls').hidden = true;
|
||||
el('runner').hidden = false;
|
||||
el('skillBundle').textContent = s.plugin;
|
||||
el('skillTitle').textContent = s.title;
|
||||
el('skillDesc').textContent = s.description;
|
||||
el('outputWrap').hidden = true;
|
||||
el('output').innerHTML = '';
|
||||
setStatus('');
|
||||
window.scrollTo({ top: 0 });
|
||||
|
||||
const form = el('inputForm');
|
||||
form.innerHTML = '';
|
||||
if (!s.inputs.length) {
|
||||
form.appendChild(makeField({ label: 'Your input / context', hint: 'Describe what you need. This skill did not declare structured inputs.', long: true }, 0));
|
||||
} else {
|
||||
s.inputs.forEach((inp, i) => form.appendChild(makeField(inp, i)));
|
||||
}
|
||||
}
|
||||
|
||||
function makeField(inp, i) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'field';
|
||||
const id = 'f_' + i;
|
||||
const opt = inp.optional ? ' <span class="opt">(optional)</span>' : '';
|
||||
const hint = inp.hint ? ` <span class="hint">— ${escapeHtml(inp.hint)}</span>` : '';
|
||||
wrap.innerHTML = `<label for="${id}">${escapeHtml(inp.label)}${opt}${hint}</label>`;
|
||||
const input = inp.long ? document.createElement('textarea') : document.createElement('input');
|
||||
input.id = id;
|
||||
input.dataset.label = inp.label;
|
||||
input.dataset.optional = inp.optional ? '1' : '';
|
||||
wrap.appendChild(input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---------- Run ----------
|
||||
async function run() {
|
||||
const key = el('apiKey').value.trim();
|
||||
if (!key) return setStatus('Enter your Claude API key first.', true);
|
||||
if (!current) return;
|
||||
|
||||
const fields = [...el('inputForm').querySelectorAll('input, textarea')];
|
||||
const missing = fields.filter((f) => !f.dataset.optional && !f.value.trim());
|
||||
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 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();
|
||||
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: 4096,
|
||||
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;
|
||||
try {
|
||||
const evt = JSON.parse(payload);
|
||||
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');
|
||||
}
|
||||
} catch (_) { /* ignore partial */ }
|
||||
}
|
||||
}
|
||||
renderMarkdown(out, acc, false);
|
||||
out.dataset.raw = acc;
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
el('runBtn').disabled = false;
|
||||
el('stopBtn').hidden = true;
|
||||
controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUserMessage(fields) {
|
||||
return fields
|
||||
.filter((f) => f.value.trim())
|
||||
.map((f) => `## ${f.dataset.label}\n${f.value.trim()}`)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function renderMarkdown(node, text, streaming) {
|
||||
node.innerHTML = DOMPurify.sanitize(marked.parse(text, { breaks: true }));
|
||||
node.classList.toggle('cursor', streaming);
|
||||
}
|
||||
|
||||
function downloadOutput() {
|
||||
const raw = el('output').dataset.raw || '';
|
||||
if (!raw) return;
|
||||
const blob = new Blob([raw], { type: 'text/markdown' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${current.name}-output.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
function setStatus(msg, isErr) {
|
||||
const s = el('status');
|
||||
s.textContent = msg;
|
||||
s.className = 'status' + (isErr ? ' err' : '');
|
||||
}
|
||||
|
||||
function parseApiError(text, status) {
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
if (j.error && j.error.message) {
|
||||
if (status === 401) return 'Invalid API key (401). Check the key and try again.';
|
||||
if (status === 429) return 'Rate limit or insufficient credits (429): ' + j.error.message;
|
||||
return `API error ${status}: ${j.error.message}`;
|
||||
}
|
||||
} catch (_) {}
|
||||
return `Request failed (${status}).`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,109 @@
|
||||
#!/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');
|
||||
|
||||
// --- 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',
|
||||
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 };
|
||||
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.`);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 497 KiB |
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Skill Playground — Product Notes</title>
|
||||
<meta name="description" content="Run any of 170+ Claude skills from your browser with your own API key. Pick a skill tile, fill the inputs, get the output." />
|
||||
<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>Skill Playground</h1>
|
||||
<p class="tagline">170+ Claude skills — pick one and run it with your own key.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="key-area">
|
||||
<div class="key-field">
|
||||
<input id="apiKey" type="password" placeholder="sk-ant-… (your Claude API key)" autocomplete="off" spellcheck="false" />
|
||||
<button id="keyToggle" type="button" class="show-toggle">Show</button>
|
||||
</div>
|
||||
<select id="model" title="Model">
|
||||
<option value="claude-opus-4-8">Opus 4.8</option>
|
||||
<option value="claude-sonnet-4-6" selected>Sonnet 4.6</option>
|
||||
<option value="claude-haiku-4-5-20251001">Haiku 4.5</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>.
|
||||
</div>
|
||||
|
||||
<div class="controls" id="controls">
|
||||
<input id="search" type="search" placeholder="Search skills…" />
|
||||
<select id="pluginFilter"><option value="">All bundles</option></select>
|
||||
<span id="count" class="count"></span>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
<!-- Tile gallery -->
|
||||
<section id="gallery" class="gallery"></section>
|
||||
|
||||
<!-- Single-skill runner -->
|
||||
<section id="runner" class="runner" hidden>
|
||||
<button id="backBtn" class="back" type="button">← All skills</button>
|
||||
|
||||
<div class="skill-head">
|
||||
<span class="bundle-tag" id="skillBundle"></span>
|
||||
<h2 id="skillTitle"></h2>
|
||||
<p id="skillDesc" class="skill-desc"></p>
|
||||
</div>
|
||||
|
||||
<form id="inputForm" class="input-form"></form>
|
||||
|
||||
<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>
|
||||
<span id="status" class="status"></span>
|
||||
</div>
|
||||
|
||||
<div class="output-wrap" id="outputWrap" hidden>
|
||||
<div class="output-toolbar">
|
||||
<span>Output</span>
|
||||
<div>
|
||||
<button id="copyBtn" class="ghost" type="button">Copy</button>
|
||||
<button id="downloadBtn" class="ghost" type="button">Download .md</button>
|
||||
</div>
|
||||
</div>
|
||||
<article id="output" class="output markdown"></article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
+167
@@ -0,0 +1,167 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #161a21;
|
||||
--panel-2: #1d222b;
|
||||
--border: #2a313c;
|
||||
--text: #e7ebf0;
|
||||
--muted: #95a0b0;
|
||||
--accent: #d97757;
|
||||
--accent-2: #e89b82;
|
||||
--radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; } /* beat .gallery/.controls display rules */
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a { color: var(--accent-2); }
|
||||
|
||||
/* ---------- Topbar ---------- */
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 16px; padding: 12px 22px; background: var(--panel);
|
||||
border-bottom: 1px solid var(--border); flex-wrap: wrap;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; }
|
||||
.brand-logo {
|
||||
height: 46px; width: auto; border-radius: 8px; background: #f5f1e8;
|
||||
padding: 4px 8px; display: block;
|
||||
}
|
||||
.brand-text h1 { font-size: 17px; margin: 0; }
|
||||
.tagline { margin: 2px 0 0; font-size: 12px; color: var(--muted); }
|
||||
|
||||
.key-area { display: flex; gap: 8px; align-items: center; }
|
||||
.key-field { position: relative; display: flex; align-items: center; }
|
||||
.key-field input {
|
||||
width: 330px; max-width: 44vw; padding: 9px 58px 9px 12px;
|
||||
background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text); font-size: 13px;
|
||||
}
|
||||
.key-field input:focus { outline: none; border-color: var(--accent); }
|
||||
.show-toggle {
|
||||
position: absolute; right: 5px; border: none; background: transparent;
|
||||
color: var(--accent-2); font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 6px;
|
||||
}
|
||||
.key-area select {
|
||||
padding: 9px 10px; background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text); font-size: 13px;
|
||||
}
|
||||
.key-note {
|
||||
font-size: 12px; color: var(--muted); padding: 8px 22px;
|
||||
background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
.controls {
|
||||
display: flex; gap: 10px; align-items: center; padding: 14px 22px;
|
||||
position: sticky; top: 0; background: var(--bg); z-index: 5;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.controls input, .controls select {
|
||||
padding: 9px 12px; background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text); font-size: 13px;
|
||||
}
|
||||
.controls input { flex: 1; max-width: 460px; }
|
||||
.controls input:focus, .controls select:focus { outline: none; border-color: var(--accent); }
|
||||
.count { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||
|
||||
/* ---------- Main / gallery ---------- */
|
||||
.main { flex: 1; padding: 22px; }
|
||||
.gallery {
|
||||
display: grid; gap: 14px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(265px, 1fr));
|
||||
}
|
||||
.skill-card {
|
||||
text-align: left; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px 16px 18px; cursor: pointer;
|
||||
color: var(--text); display: flex; flex-direction: column; gap: 7px;
|
||||
transition: border-color .12s, transform .12s, background .12s;
|
||||
}
|
||||
.skill-card:hover { border-color: var(--accent); transform: translateY(-2px); background: var(--panel-2); }
|
||||
.card-bundle {
|
||||
font-size: 10.5px; letter-spacing: .03em; text-transform: uppercase;
|
||||
color: var(--accent-2); font-weight: 600;
|
||||
}
|
||||
.card-title { margin: 0; font-size: 15px; line-height: 1.25; }
|
||||
.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; }
|
||||
|
||||
/* ---------- Runner ---------- */
|
||||
.runner { max-width: 880px; margin: 0 auto; }
|
||||
.back {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 7px 13px; border-radius: 8px; font-size: 13px; cursor: pointer; margin-bottom: 18px;
|
||||
}
|
||||
.back:hover { border-color: var(--accent); }
|
||||
|
||||
.skill-head { margin-bottom: 18px; }
|
||||
.bundle-tag {
|
||||
display: inline-block; font-size: 11px; color: var(--accent-2);
|
||||
background: rgba(217,119,87,.12); padding: 3px 9px; border-radius: 99px; margin-bottom: 8px;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
.field .opt { color: var(--muted); font-weight: 400; }
|
||||
.field .hint { color: var(--muted); font-weight: 400; font-size: 12px; }
|
||||
.field input, .field textarea {
|
||||
width: 100%; padding: 10px 12px; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text); font-size: 13.5px; font-family: inherit; resize: vertical;
|
||||
}
|
||||
.field textarea { min-height: 90px; }
|
||||
.field input:focus, .field textarea:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 12px; }
|
||||
button.primary {
|
||||
background: var(--accent); color: #1a1207; border: none; padding: 11px 18px;
|
||||
border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
button.primary:hover { background: var(--accent-2); }
|
||||
button.primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
button.ghost {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 8px 13px; border-radius: 8px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
button.ghost:hover { border-color: var(--accent); }
|
||||
.status { font-size: 13px; color: var(--muted); }
|
||||
.status.err { color: #e07a6f; }
|
||||
|
||||
/* ---------- Output ---------- */
|
||||
.output-wrap { margin-top: 24px; }
|
||||
.output-toolbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 12px; color: var(--muted); margin-bottom: 8px;
|
||||
}
|
||||
.output-toolbar > div { display: flex; gap: 8px; }
|
||||
.output {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 22px 26px; line-height: 1.6; font-size: 14.5px;
|
||||
}
|
||||
.markdown h1, .markdown h2, .markdown h3 { line-height: 1.3; }
|
||||
.markdown h1 { font-size: 22px; }
|
||||
.markdown h2 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 5px; }
|
||||
.markdown h3 { font-size: 15px; }
|
||||
.markdown code { background: var(--panel-2); padding: 2px 6px; border-radius: 5px; font-size: 13px; }
|
||||
.markdown pre { background: var(--panel-2); padding: 14px; border-radius: 8px; overflow-x: auto; }
|
||||
.markdown pre code { background: none; padding: 0; }
|
||||
.markdown table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
.markdown th, .markdown td { border: 1px solid var(--border); padding: 7px 10px; text-align: left; font-size: 13px; }
|
||||
.markdown th { background: var(--panel-2); }
|
||||
.markdown blockquote { border-left: 3px solid var(--accent); margin: 12px 0; padding: 2px 14px; color: var(--muted); }
|
||||
.cursor::after { content: "▍"; color: var(--accent); animation: blink 1s steps(2) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.key-field input { width: 200px; }
|
||||
.brand-text h1 { font-size: 15px; }
|
||||
.gallery { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user