Add author auth, forking, tags, and stats tracking
Introduce token-based author authentication (register/verify API), skill forking with EditGate protection, tag metadata on skills, and download/push stats. Enhanced push scripts with token auth and per-skill filtering. Updated UI with stats, tags, and author info on skill cards.
This commit is contained in:
@@ -3,20 +3,35 @@ import Base from '../layouts/Base.astro';
|
||||
import SkillCard from '../components/SkillCard.astro';
|
||||
import SkillSearch from '../components/SkillSearch.vue';
|
||||
import { listSkills } from '../lib/skills';
|
||||
import { buildSyncScript } from '../lib/sync';
|
||||
import { getAllStats } from '../lib/stats';
|
||||
import { buildSyncScript, buildSyncScriptPS, isPowerShell } from '../lib/sync';
|
||||
|
||||
const accept = Astro.request.headers.get('accept') || '';
|
||||
if (!accept.includes('text/html')) {
|
||||
const script = await buildSyncScript(Astro.url.origin, '.claude/skills');
|
||||
const ps = isPowerShell(Astro.request);
|
||||
const script = ps
|
||||
? await buildSyncScriptPS(Astro.url.origin, '.claude\\skills')
|
||||
: await buildSyncScript(Astro.url.origin, '.claude/skills');
|
||||
return new Response(script, {
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
const skills = await listSkills();
|
||||
|
||||
// Compute fork counts and unique authors
|
||||
const forkCounts = new Map<string, number>();
|
||||
for (const s of skills) {
|
||||
if (s['fork-of']) {
|
||||
forkCounts.set(s['fork-of'], (forkCounts.get(s['fork-of']) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const authors = [...new Set(skills.map(s => s.author).filter(Boolean))].sort();
|
||||
const allTags = [...new Set(skills.flatMap(s => s.tags))].sort();
|
||||
const allStats = await getAllStats();
|
||||
---
|
||||
|
||||
<Base title="Skillit — Claude Code Skills">
|
||||
<Base title="Skills">
|
||||
{skills.length === 0 ? (
|
||||
<div class="text-center py-24">
|
||||
<div class="inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-200 border border-white/[0.06] mb-6">
|
||||
@@ -39,64 +54,187 @@ const skills = await listSkills();
|
||||
) : (
|
||||
<div>
|
||||
<!-- Hero / Quick install -->
|
||||
<div class="mb-10">
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-white mb-2">Skills</h1>
|
||||
<p class="text-gray-500 mb-5 max-w-xl">Manage and distribute Claude Code skills. Skills are prompt files that Claude loads automatically to learn custom behaviors and workflows.</p>
|
||||
<div class="mb-10 grid gap-6 lg:grid-cols-2 lg:items-start">
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6">
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-white mb-2">Skills</h1>
|
||||
<p class="text-gray-500 mb-3">Manage and distribute Claude Code skills. Skills are prompt files (<code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded text-xs">.md</code>) that Claude loads automatically to learn custom behaviors and workflows.</p>
|
||||
<p class="text-gray-600 text-sm leading-relaxed">Create reusable skills to standardize how Claude handles commits, code reviews, testing, deployments, and more across your team. Share them instantly with a single curl command.</p>
|
||||
</div>
|
||||
|
||||
<!-- Install instructions -->
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 max-w-2xl space-y-4">
|
||||
<h2 class="text-sm font-semibold text-white">Quick install</h2>
|
||||
<p class="text-xs text-gray-500 leading-relaxed">Run this in your project root to sync all skills. They'll be saved to <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/</code> and Claude Code will pick them up automatically on the next conversation.</p>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
|
||||
<code id="install-cmd" class="flex-1 text-sm font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin} | bash</code>
|
||||
<button
|
||||
id="copy-btn"
|
||||
class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="group">
|
||||
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">More options</summary>
|
||||
<div class="mt-3 space-y-3 text-xs text-gray-500">
|
||||
<div>
|
||||
<p class="mb-1.5">Install globally (available in all projects):</p>
|
||||
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
|
||||
<code class="flex-1 font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/gi | bash</code>
|
||||
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
<!-- Quick install + Quick push -->
|
||||
<div class="space-y-2">
|
||||
<!-- Quick install -->
|
||||
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
|
||||
<summary class="flex items-center justify-between cursor-pointer px-6 py-4 select-none">
|
||||
<h2 class="text-sm font-semibold text-white">Quick install</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="hidden group-open:flex rounded-lg border border-white/[0.06] overflow-hidden os-tabs" onclick="event.stopPropagation()">
|
||||
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
|
||||
<button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">Windows</button>
|
||||
</div>
|
||||
<svg class="h-4 w-4 text-gray-600 group-open:rotate-180 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1.5">Push local skills to the server:</p>
|
||||
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
|
||||
<code class="flex-1 font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/p | bash</code>
|
||||
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</summary>
|
||||
<div class="px-6 pb-5 space-y-3">
|
||||
<p class="text-xs text-gray-500 leading-relaxed">Sync all skills to your project. They'll be saved to <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/</code> and Claude Code picks them up on the next conversation.</p>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
|
||||
<code data-cmd="unix" class="flex-1 text-sm font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin} | bash</code>
|
||||
<code data-cmd="win" class="flex-1 text-sm font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin} | iex</code>
|
||||
<button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</div>
|
||||
<details class="group/inner">
|
||||
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">Install globally</summary>
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
|
||||
<code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/gi | bash</code>
|
||||
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin}/gi | iex</code>
|
||||
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 text-xs font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Quick push -->
|
||||
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
|
||||
<summary class="flex items-center justify-between cursor-pointer px-6 py-4 select-none">
|
||||
<h2 class="text-sm font-semibold text-white">Quick push</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="hidden group-open:flex rounded-lg border border-white/[0.06] overflow-hidden os-tabs" onclick="event.stopPropagation()">
|
||||
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
|
||||
<button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">Windows</button>
|
||||
</div>
|
||||
<svg class="h-4 w-4 text-gray-600 group-open:rotate-180 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="px-6 pb-5 space-y-3">
|
||||
<p class="text-xs text-gray-500 leading-relaxed">Push your local skills to the server. Run from your project root — it reads <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/*.md</code> and uploads them.</p>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
|
||||
<code data-cmd="unix" class="flex-1 text-sm font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/p | bash</code>
|
||||
<code data-cmd="win" class="flex-1 text-sm font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin}/p | iex</code>
|
||||
<button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</div>
|
||||
<details class="group/inner">
|
||||
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">Push a specific skill</summary>
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
|
||||
<code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/p | bash -s skill-name</code>
|
||||
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin}/p | iex -Args skill-name</code>
|
||||
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 text-xs font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Grid -->
|
||||
<SkillSearch client:load />
|
||||
<!-- Search + Grid + Table -->
|
||||
<SkillSearch authors={authors.join(',')} tags={allTags.join(',')} totalCount={skills.length} client:load />
|
||||
<div id="skills-grid" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{skills.map((skill) => (
|
||||
<div data-skill data-name={skill.name.toLowerCase()} data-description={skill.description.toLowerCase()} data-tools={skill['allowed-tools'].join(' ').toLowerCase()}>
|
||||
<SkillCard {...skill} />
|
||||
<div
|
||||
data-skill
|
||||
data-name={skill.name.toLowerCase()}
|
||||
data-description={skill.description.toLowerCase()}
|
||||
data-tools={skill['allowed-tools'].join(' ').toLowerCase()}
|
||||
data-author={skill.author.toLowerCase()}
|
||||
data-tags={skill.tags.join(',').toLowerCase()}
|
||||
data-forks={String(forkCounts.get(skill.slug) || 0)}
|
||||
>
|
||||
<SkillCard {...skill} forkCount={forkCounts.get(skill.slug) || 0} downloads={allStats[skill.slug]?.downloads || 0} pushes={allStats[skill.slug]?.pushes || 0} lastPushedAt={allStats[skill.slug]?.lastPushedAt || null} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div id="skills-table" class="hidden overflow-x-auto rounded-2xl border border-white/[0.06]">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="bg-surface-100 border-b border-white/[0.06]">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Name</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Description</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Tools</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Tags</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Author</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/[0.04]">
|
||||
{skills.map((skill) => {
|
||||
const desc = skill.description.length > 80 ? skill.description.slice(0, 80) + '...' : skill.description;
|
||||
const fc = forkCounts.get(skill.slug) || 0;
|
||||
const st = allStats[skill.slug] || { downloads: 0, pushes: 0 };
|
||||
return (
|
||||
<tr
|
||||
data-skill
|
||||
data-name={skill.name.toLowerCase()}
|
||||
data-description={skill.description.toLowerCase()}
|
||||
data-tools={skill['allowed-tools'].join(' ').toLowerCase()}
|
||||
data-author={skill.author.toLowerCase()}
|
||||
data-tags={skill.tags.join(',').toLowerCase()}
|
||||
data-forks={String(fc)}
|
||||
class="bg-surface-50 hover:bg-surface-100 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href={`/${skill.slug}`} class="font-medium text-white hover:text-accent-400 transition-colors">{skill.name}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 max-w-xs truncate">{desc}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{skill['allowed-tools'].map((tool) => (
|
||||
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-1.5 py-0.5 text-[11px] font-medium text-gray-400">{tool}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{skill.tags.map((tag) => (
|
||||
<span class="rounded-full bg-[var(--color-accent-500)]/10 border border-[var(--color-accent-500)]/20 px-2 py-0.5 text-[11px] font-medium text-[var(--color-accent-400)]">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{skill.author || '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">{allStats[skill.slug]?.lastPushedAt ? new Date(allStats[skill.slug].lastPushedAt!).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.os-tab { color: var(--color-gray-600, #6b7280); }
|
||||
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
|
||||
</style>
|
||||
<script>
|
||||
document.querySelectorAll<HTMLButtonElement>('#copy-btn, [data-copy]').forEach((btn) => {
|
||||
// OS detection + tab switching
|
||||
const isWin = /Win/.test(navigator.platform);
|
||||
function setOS(os: string) {
|
||||
document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(el => {
|
||||
el.classList.toggle('hidden', el.dataset.cmd !== os);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>('.os-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.os === os);
|
||||
});
|
||||
}
|
||||
setOS(isWin ? 'win' : 'unix');
|
||||
document.querySelectorAll<HTMLButtonElement>('.os-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => setOS(tab.dataset.os!));
|
||||
});
|
||||
|
||||
// Copy buttons
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const code = btn.previousElementSibling?.textContent?.trim();
|
||||
const container = btn.parentElement!;
|
||||
const visible = container.querySelector<HTMLElement>('[data-cmd]:not(.hidden)');
|
||||
const code = visible?.textContent?.trim();
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code);
|
||||
btn.textContent = 'Copied!';
|
||||
|
||||
Reference in New Issue
Block a user