- Rename Grimaired -> Grimoired everywhere (title, nav, descriptions, token keys) - Update domain from skills.here.run.place to grimoi.red - Add Grimoired logo with description on homepage - Add accordion behavior for Quick install / Quick push sections - Add generic resource system (skills, agents, output-styles, rules) - Add resource registry, editor, search, and file manager components
300 lines
16 KiB
Plaintext
300 lines
16 KiB
Plaintext
---
|
|
import Base from '../layouts/Base.astro';
|
|
import ResourceCard from '../components/ResourceCard.astro';
|
|
import ResourceSearch from '../components/ResourceSearch.vue';
|
|
import { listResources, listAllResources } from '../lib/resources';
|
|
import { RESOURCE_TYPES, REGISTRY } from '../lib/registry';
|
|
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 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' },
|
|
});
|
|
}
|
|
|
|
// Fetch all resources grouped by type
|
|
const resourcesByType: Record<string, Awaited<ReturnType<typeof listResources>>> = {};
|
|
const typeCounts: Record<string, number> = {};
|
|
for (const type of RESOURCE_TYPES) {
|
|
const resources = await listResources(type);
|
|
resourcesByType[type] = resources;
|
|
typeCounts[type] = resources.length;
|
|
}
|
|
|
|
const allResources = Object.entries(resourcesByType).flatMap(([type, resources]) =>
|
|
resources.map(r => ({ ...r, type }))
|
|
).sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
// Compute fork counts
|
|
const forkCounts = new Map<string, number>();
|
|
for (const r of allResources) {
|
|
if (r['fork-of']) {
|
|
const key = `${r.type}:${r['fork-of']}`;
|
|
forkCounts.set(key, (forkCounts.get(key) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
const authors = [...new Set(allResources.map(r => r.author).filter(Boolean))].sort();
|
|
const allTags = [...new Set(allResources.flatMap(r => r.tags))].sort();
|
|
|
|
// Get stats for all types
|
|
const allStatsMap: Record<string, Record<string, { downloads: number; pushes: number; lastPushedAt: string | null }>> = {};
|
|
for (const type of RESOURCE_TYPES) {
|
|
allStatsMap[type] = await getAllStats(type);
|
|
}
|
|
|
|
function parseTools(val: unknown): string[] {
|
|
if (Array.isArray(val)) return val.map(String);
|
|
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
|
|
return [];
|
|
}
|
|
---
|
|
|
|
<Base title="Grimoired">
|
|
{allResources.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">
|
|
<svg class="h-7 w-7 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
|
</svg>
|
|
</div>
|
|
<p class="text-gray-500 text-lg mb-2">No resources yet</p>
|
|
<p class="text-gray-600 text-sm mb-6">Create your first skill, agent, output style, or rule to get started.</p>
|
|
<a
|
|
href="/skills/new"
|
|
class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 transition-all"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
</svg>
|
|
Create your first resource
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<!-- Hero / Quick install -->
|
|
<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">
|
|
<div class="flex items-center gap-4 mb-3">
|
|
<img src="/grimoired.svg" alt="Grimoired logo" class="h-12 w-12" />
|
|
<h1 class="text-3xl font-extrabold tracking-tight text-white">Grimoired</h1>
|
|
</div>
|
|
<p class="text-gray-500 mb-3"><strong class="text-gray-400">Grimoired</strong> (from <em>grimoire</em> — a book of spells, originally French) is a shared registry for Claude Code resources: skills, agents, output styles, and rules. Resources can be simple prompt files (<code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded text-xs">.md</code>) or full packages with scripts, references, and assets that Claude picks up automatically.</p>
|
|
<p class="text-gray-600 text-sm leading-relaxed">Create, browse, and share reusable prompts that standardize how Claude handles tasks across your team. Install them instantly with a single curl command.</p>
|
|
</div>
|
|
|
|
<!-- Quick install + Quick push -->
|
|
<div class="space-y-2">
|
|
<details open data-accordion 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 (skills)</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">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>
|
|
|
|
<details data-accordion 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 (skills)</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>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search + Grid + Table -->
|
|
<ResourceSearch
|
|
authors={authors.join(',')}
|
|
tags={allTags.join(',')}
|
|
totalCount={allResources.length}
|
|
typeCounts={JSON.stringify(typeCounts)}
|
|
client:load
|
|
/>
|
|
|
|
<div id="resources-grid" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{allResources.map((r) => {
|
|
const config = REGISTRY[r.type as keyof typeof REGISTRY];
|
|
const stats = allStatsMap[r.type]?.[r.slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
|
|
const fc = forkCounts.get(`${r.type}:${r.slug}`) || 0;
|
|
const tools = parseTools(r.fields['allowed-tools'] ?? r.fields.allowedTools);
|
|
return (
|
|
<div
|
|
data-resource
|
|
data-name={r.name.toLowerCase()}
|
|
data-description={r.description.toLowerCase()}
|
|
data-tools={tools.join(' ').toLowerCase()}
|
|
data-author={r.author.toLowerCase()}
|
|
data-tags={r.tags.join(',').toLowerCase()}
|
|
data-type={r.type}
|
|
data-forks={String(fc)}
|
|
>
|
|
<ResourceCard
|
|
resourceType={r.type}
|
|
slug={r.slug}
|
|
name={r.name}
|
|
description={r.description}
|
|
tags={r.tags}
|
|
author={r.author}
|
|
forkCount={fc}
|
|
downloads={stats.downloads}
|
|
pushes={stats.pushes}
|
|
lastPushedAt={stats.lastPushedAt}
|
|
typeLabel={config.labelSingular}
|
|
typeColor={config.color}
|
|
tools={tools}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div id="resources-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">Type</th>
|
|
<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">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]">
|
|
{allResources.map((r) => {
|
|
const config = REGISTRY[r.type as keyof typeof REGISTRY];
|
|
const stats = allStatsMap[r.type]?.[r.slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
|
|
const fc = forkCounts.get(`${r.type}:${r.slug}`) || 0;
|
|
const desc = r.description.length > 80 ? r.description.slice(0, 80) + '...' : r.description;
|
|
const tools = parseTools(r.fields['allowed-tools'] ?? r.fields.allowedTools);
|
|
return (
|
|
<tr
|
|
data-resource
|
|
data-name={r.name.toLowerCase()}
|
|
data-description={r.description.toLowerCase()}
|
|
data-tools={tools.join(' ').toLowerCase()}
|
|
data-author={r.author.toLowerCase()}
|
|
data-tags={r.tags.join(',').toLowerCase()}
|
|
data-type={r.type}
|
|
data-forks={String(fc)}
|
|
class="bg-surface-50 hover:bg-surface-100 transition-colors"
|
|
>
|
|
<td class="px-4 py-3 whitespace-nowrap">
|
|
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" style={`background: ${config.color}20; color: ${config.color};`}>
|
|
{config.labelSingular}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap">
|
|
<a href={`/${r.type}/${r.slug}`} class="font-medium text-white hover:text-accent-400 transition-colors">{r.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">
|
|
{r.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">{r.author || '—'}</td>
|
|
<td class="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">{stats.lastPushedAt ? new Date(stats.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>
|
|
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!));
|
|
});
|
|
|
|
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
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!';
|
|
setTimeout(() => btn.textContent = 'Copy', 1500);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Accordion: only one details[data-accordion] open at a time
|
|
document.querySelectorAll<HTMLDetailsElement>('details[data-accordion]').forEach((detail) => {
|
|
detail.addEventListener('toggle', () => {
|
|
if (detail.open) {
|
|
document.querySelectorAll<HTMLDetailsElement>('details[data-accordion]').forEach((other) => {
|
|
if (other !== detail) other.removeAttribute('open');
|
|
});
|
|
}
|
|
});
|
|
});
|
|
</script>
|