Rename to Grimoired, update domain to grimoi.red, add resource system

- 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
This commit is contained in:
Alejandro Martinez
2026-02-13 14:24:53 +01:00
committed by Alejandro Martinez
parent aa477a553b
commit 17423fb3b9
27 changed files with 4389 additions and 181 deletions

View File

@@ -9,12 +9,15 @@ FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/data/skills ./data/skills
COPY --from=build /app/data ./data
ENV HOST=0.0.0.0
ENV PORT=4321
ENV SKILLS_DIR=/app/data/skills
ENV SITE_URL=https://skills.here.run.place
ENV AGENTS_DIR=/app/data/agents
ENV OUTPUT_STYLES_DIR=/app/data/output-styles
ENV RULES_DIR=/app/data/rules
ENV SITE_URL=https://grimoi.red
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -4,7 +4,7 @@ import vue from '@astrojs/vue';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
site: process.env.SITE_URL || 'https://skills.here.run.place',
site: process.env.SITE_URL || 'https://grimoi.red',
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [vue()],

18
grimoired.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

18
public/grimoired.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -11,13 +11,13 @@
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
<!-- Token modal for protected skills -->
<!-- Token modal for protected resources -->
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showModal = false">
<div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl">
<h3 class="text-lg font-semibold text-red-400 mb-1">Delete Skill</h3>
<h3 class="text-lg font-semibold text-red-400 mb-1">Delete Resource</h3>
<p class="text-sm text-gray-500 mb-4">
This skill is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to delete it.
This resource is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to delete it.
</p>
<form @submit.prevent="verifyAndDelete">
@@ -65,8 +65,11 @@ const props = defineProps<{
authorEmail?: string;
authorName?: string;
authorHasToken?: boolean;
resourceType?: string;
}>();
const type = props.resourceType || 'skills';
const deleting = ref(false);
const showModal = ref(false);
const token = ref('');
@@ -75,8 +78,7 @@ const tokenInput = ref<HTMLInputElement>();
async function handleClick() {
if (props.authorEmail && props.authorHasToken) {
// Try saved token first
const saved = localStorage.getItem('skillshere-token') || '';
const saved = localStorage.getItem('grimoired-token') || '';
if (saved) {
try {
const res = await fetch('/api/auth/verify', {
@@ -100,7 +102,6 @@ async function handleClick() {
}
async function verifyAndDelete() {
// Verify token first
error.value = '';
deleting.value = true;
@@ -123,7 +124,7 @@ async function verifyAndDelete() {
return;
}
localStorage.setItem('skillshere-token', token.value);
localStorage.setItem('grimoired-token', token.value);
doDelete(token.value);
}
@@ -142,7 +143,7 @@ async function doDelete(authToken: string) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const res = await fetch(`/api/skills/${props.slug}`, { method: 'DELETE', headers });
const res = await fetch(`/api/resources/${type}/${props.slug}`, { method: 'DELETE', headers });
if (res.status === 403) {
const data = await res.json();
error.value = data.error || 'Permission denied';
@@ -156,7 +157,7 @@ async function doDelete(authToken: string) {
}
window.location.href = '/';
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete skill.';
error.value = err instanceof Error ? err.message : 'Failed to delete.';
deleting.value = false;
}
}

View File

@@ -16,7 +16,7 @@
<div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl">
<h3 class="text-lg font-semibold text-white mb-1">Author Verification</h3>
<p class="text-sm text-gray-500 mb-4">
This skill is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to edit.
This resource is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to edit.
</p>
<form @submit.prevent="verify">
@@ -44,7 +44,7 @@
<button
type="button"
@click="forkSkill"
@click="forkResource"
class="text-sm text-[var(--color-accent-400)] hover:text-[var(--color-accent-300)] transition-colors"
>
Fork instead
@@ -72,8 +72,11 @@ const props = defineProps<{
authorEmail?: string;
authorName?: string;
authorHasToken?: boolean;
resourceType?: string;
}>();
const type = props.resourceType || 'skills';
const showModal = ref(false);
const token = ref('');
const error = ref('');
@@ -82,12 +85,11 @@ const tokenInput = ref<HTMLInputElement>();
async function handleClick() {
if (!props.authorEmail || !props.authorHasToken) {
window.location.href = `/${props.slug}/edit`;
window.location.href = `/${type}/${props.slug}/edit`;
return;
}
// Try saved token first
const saved = localStorage.getItem('skillshere-token') || '';
const saved = localStorage.getItem('grimoired-token') || '';
if (saved) {
try {
const res = await fetch('/api/auth/verify', {
@@ -96,8 +98,8 @@ async function handleClick() {
body: JSON.stringify({ email: props.authorEmail, token: saved }),
});
if (res.ok) {
localStorage.setItem('skillshere-token', saved);
window.location.href = `/${props.slug}/edit`;
localStorage.setItem('grimoired-token', saved);
window.location.href = `/${type}/${props.slug}/edit`;
return;
}
} catch { /* fall through to modal */ }
@@ -126,9 +128,8 @@ async function verify() {
return;
}
// Store token for the editor to use
localStorage.setItem('skillshere-token', token.value);
window.location.href = `/${props.slug}/edit`;
localStorage.setItem('grimoired-token', token.value);
window.location.href = `/${type}/${props.slug}/edit`;
} catch {
error.value = 'Could not verify token';
} finally {
@@ -136,8 +137,8 @@ async function verify() {
}
}
function forkSkill() {
function forkResource() {
showModal.value = false;
window.location.href = `/new?from=${encodeURIComponent(props.slug)}`;
window.location.href = `/${type}/new?from=${encodeURIComponent(props.slug)}`;
}
</script>

View File

@@ -0,0 +1,267 @@
<template>
<!-- Text input -->
<div v-if="field.type === 'text'">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
type="text"
:placeholder="field.placeholder || ''"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
<!-- Number input -->
<div v-else-if="field.type === 'number'">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
type="number"
:placeholder="field.placeholder || ''"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
<!-- Select -->
<div v-else-if="field.type === 'select'">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<select
:value="modelValue"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
>
<option v-if="field.dynamicOptions === 'models'" value="">Default</option>
<option v-for="opt in resolvedOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
<!-- Toggle -->
<div v-else-if="field.type === 'toggle'">
<label class="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
:checked="Boolean(modelValue)"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
class="sr-only peer"
/>
<div class="h-5 w-9 rounded-full bg-white/[0.06] border border-white/[0.06] peer-checked:bg-[var(--color-accent-500)] peer-checked:border-[var(--color-accent-500)] relative transition-all after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-4 after:w-4 after:rounded-full after:bg-gray-400 after:transition-all peer-checked:after:translate-x-4 peer-checked:after:bg-white"></div>
<span class="text-xs text-gray-500 group-hover:text-gray-300 transition-colors">
{{ field.label }}
<span v-if="field.hint" class="text-gray-600">({{ field.hint }})</span>
</span>
</label>
</div>
<!-- Toggle grid (tools, etc) -->
<div v-else-if="field.type === 'toggle-grid'">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<div class="flex flex-wrap gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2.5 min-h-[42px]">
<button
v-for="opt in gridOptions"
:key="opt"
type="button"
@click="toggleGridItem(opt)"
:class="[
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
selectedSet.has(opt)
? 'bg-[var(--color-accent-500)] text-white shadow-sm'
: 'bg-white/[0.04] border border-white/[0.06] text-gray-500 hover:text-gray-300 hover:bg-white/[0.08]'
]"
>
{{ opt }}
</button>
</div>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
<!-- JSON editor -->
<div v-else-if="field.type === 'json'">
<details class="group">
<summary class="text-xs font-medium uppercase tracking-wider text-gray-500 cursor-pointer hover:text-gray-400 transition-colors">{{ field.label }} (advanced)</summary>
<div class="mt-3">
<textarea
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
rows="4"
:placeholder="field.placeholder || ''"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-700 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all resize-y"
/>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
</details>
</div>
<!-- Tags input -->
<div v-else-if="field.type === 'tags'" class="relative">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<div
class="flex flex-wrap items-center gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2 min-h-[42px] cursor-text focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all"
@click="tagInputRef?.focus()"
>
<span
v-for="(tag, i) in tagsList"
:key="tag"
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 border border-[var(--color-accent-500)]/25 pl-2.5 pr-1.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]"
>
{{ tag }}
<button type="button" @click.stop="removeTagItem(i)" class="rounded-full p-0.5 hover:bg-[var(--color-accent-500)]/30 transition-colors">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</span>
<input
ref="tagInputRef"
v-model="tagInput"
type="text"
:placeholder="field.placeholder || 'Add item...'"
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
@keydown="onTagKeydown"
@focus="tagSuggestionsOpen = true"
@input="tagSuggestionsOpen = true"
@blur="onTagBlur"
/>
</div>
<div
v-if="tagSuggestionsOpen && tagSuggestions.length > 0"
class="absolute z-10 mt-1 w-full max-h-40 overflow-auto rounded-xl border border-white/[0.08] bg-[var(--color-surface-200)] shadow-xl"
>
<button
v-for="s in tagSuggestions"
:key="s"
type="button"
@mousedown.prevent="onTagSuggestionClick(s)"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors text-left"
>
{{ s }}
</button>
</div>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
interface FieldDef {
key: string;
label: string;
type: 'text' | 'select' | 'toggle-grid' | 'toggle' | 'number' | 'json' | 'tags';
placeholder?: string;
hint?: string;
options?: Array<{ value: string; label: string }>;
dynamicOptions?: 'tools' | 'models' | 'skills';
defaultValue?: unknown;
}
const props = defineProps<{
field: FieldDef;
modelValue: unknown;
tools?: string[];
models?: Array<{ id: string; display_name: string }>;
skills?: string[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: unknown];
}>();
// --- Select options ---
const resolvedOptions = computed(() => {
if (props.field.dynamicOptions === 'models' && props.models) {
return props.models.map(m => ({ value: m.id, label: m.display_name }));
}
return props.field.options || [];
});
// --- Toggle grid ---
const gridOptions = computed(() => {
if (props.field.dynamicOptions === 'tools' && props.tools) {
return props.tools;
}
return (props.field.options || []).map(o => o.value);
});
const selectedSet = computed(() => {
const val = props.modelValue;
if (val instanceof Set) return val as Set<string>;
if (Array.isArray(val)) return new Set(val as string[]);
if (typeof val === 'string') return new Set(val.split(',').map(t => t.trim()).filter(Boolean));
return new Set<string>();
});
function toggleGridItem(item: string) {
const current = new Set(selectedSet.value);
if (current.has(item)) {
current.delete(item);
} else {
current.add(item);
}
emit('update:modelValue', [...current]);
}
// --- Tags input ---
const tagInput = ref('');
const tagInputRef = ref<HTMLInputElement>();
const tagSuggestionsOpen = ref(false);
const tagsList = computed(() => {
const val = props.modelValue;
if (Array.isArray(val)) return val as string[];
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
});
const knownTagOptions = computed(() => {
if (props.field.dynamicOptions === 'skills' && props.skills) return props.skills;
return [];
});
const tagSuggestions = computed(() => {
if (knownTagOptions.value.length === 0) return [];
const q = tagInput.value.toLowerCase().trim();
const current = new Set(tagsList.value.map(t => t.toLowerCase()));
return knownTagOptions.value.filter(t =>
!current.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q))
);
});
function addTagItem(val: string) {
const trimmed = val.trim();
if (trimmed && !tagsList.value.includes(trimmed)) {
emit('update:modelValue', [...tagsList.value, trimmed]);
}
tagInput.value = '';
tagInputRef.value?.focus();
}
function removeTagItem(idx: number) {
const list = [...tagsList.value];
list.splice(idx, 1);
emit('update:modelValue', list);
}
let tagBlurTimer: ReturnType<typeof setTimeout>;
function onTagBlur() {
tagBlurTimer = setTimeout(() => { tagSuggestionsOpen.value = false; }, 200);
}
function onTagSuggestionClick(tag: string) {
clearTimeout(tagBlurTimer);
addTagItem(tag);
}
function onTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTagItem(tagInput.value);
} else if (e.key === 'Backspace' && !tagInput.value && tagsList.value.length) {
const list = [...tagsList.value];
list.pop();
emit('update:modelValue', list);
}
}
</script>

View File

@@ -0,0 +1,357 @@
<template>
<div class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-50)] p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">Files</h3>
<div class="flex gap-1.5">
<button
type="button"
@click="addMode = addMode === 'create' ? '' : 'create'"
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
addMode === 'create'
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
Create
</button>
<button
type="button"
@click="addMode = addMode === 'upload' ? '' : 'upload'"
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
addMode === 'upload'
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
Upload
</button>
</div>
</div>
<!-- Create inline file -->
<div v-if="addMode === 'create'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3 space-y-3">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
<select v-model="dir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
<option value="scripts">scripts/ executable code</option>
<option value="references">references/ docs &amp; context</option>
<option value="assets">assets/ templates &amp; files</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-500 mb-1">File name</label>
<input
v-model="newFileName"
type="text"
placeholder="run.sh"
class="w-full rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm font-mono text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none transition-all"
@keydown.enter.prevent="createInlineFile"
/>
</div>
<button type="button" @click="createInlineFile" :disabled="!newFileName.trim() || saving" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all">Create</button>
</div>
<p class="text-[11px] text-gray-600"><strong class="text-gray-500">scripts/</strong> run via hooks or tool calls &middot; <strong class="text-gray-500">references/</strong> Claude reads as context &middot; <strong class="text-gray-500">assets/</strong> copied into the project</p>
<span v-if="createError" class="text-xs text-red-400">{{ createError }}</span>
</div>
<!-- Upload file -->
<div v-if="addMode === 'upload'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3 space-y-3">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
<select v-model="dir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
<option value="scripts">scripts/ executable code</option>
<option value="references">references/ docs &amp; context</option>
<option value="assets">assets/ templates &amp; files</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-500 mb-1">File</label>
<input
ref="fileInput"
type="file"
@change="onFileSelected"
class="w-full text-sm text-gray-400 file:mr-2 file:rounded-lg file:border-0 file:bg-white/[0.06] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-gray-300 hover:file:bg-white/[0.1] file:cursor-pointer file:transition-all"
/>
</div>
<button type="button" @click="uploadFile" :disabled="!selectedFile || saving" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all">{{ saving ? 'Uploading...' : 'Upload' }}</button>
</div>
<span v-if="uploadError" class="text-xs text-red-400">{{ uploadError }}</span>
</div>
<!-- File list -->
<div v-if="fileList.length > 0" class="space-y-2">
<div v-for="f in fileList" :key="f.relativePath" class="rounded-lg border border-white/[0.06] overflow-hidden">
<!-- File header -->
<div class="flex items-center gap-2 px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors group">
<button v-if="isTextFile(f.relativePath)" type="button" @click="toggleExpand(f.relativePath)" class="shrink-0 text-gray-600 hover:text-gray-400 transition-colors">
<svg :class="['h-3.5 w-3.5 transition-transform', expanded[f.relativePath] ? 'rotate-90' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
<svg v-else class="h-3.5 w-3.5 shrink-0 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.25m2.25 0H5.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>
<span class="flex-1 text-sm text-gray-400 truncate font-mono">{{ f.relativePath }}</span>
<span class="shrink-0 text-xs text-gray-600">{{ formatSize(f.size) }}</span>
<button
type="button"
@click="deleteFile(f.relativePath)"
class="shrink-0 opacity-0 group-hover:opacity-100 rounded p-1 text-gray-600 hover:text-red-400 hover:bg-red-400/10 transition-all"
title="Delete file"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
<!-- Inline editor for text files -->
<div v-if="isTextFile(f.relativePath) && expanded[f.relativePath]" class="border-t border-white/[0.06]">
<div v-if="fileContents[f.relativePath] === undefined" class="px-4 py-3 text-xs text-gray-600">Loading...</div>
<template v-else>
<textarea
v-model="fileContents[f.relativePath]"
rows="10"
:placeholder="filePlaceholder(f.relativePath)"
class="w-full bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-600 focus:outline-none resize-y leading-relaxed"
/>
<div class="flex items-center gap-2 px-3 py-2 border-t border-white/[0.06] bg-white/[0.02]">
<button
type="button"
@click="saveFileContent(f.relativePath)"
:disabled="savingFile === f.relativePath"
class="rounded-lg bg-[var(--color-accent-500)] px-3 py-1 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all"
>{{ savingFile === f.relativePath ? 'Saving...' : 'Save' }}</button>
<span v-if="fileSaveStatus[f.relativePath]" class="text-xs" :class="fileSaveStatus[f.relativePath] === 'saved' ? 'text-emerald-400' : 'text-red-400'">
{{ fileSaveStatus[f.relativePath] === 'saved' ? 'Saved' : fileSaveStatus[f.relativePath] }}
</span>
</div>
</template>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-600">No files yet. Create scripts, docs, or upload assets.</p>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
interface FileEntry {
relativePath: string;
size: number;
}
const BINARY_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'svg', 'bmp', 'tiff',
'zip', 'gz', 'tar', 'bz2', 'xz', '7z', 'rar',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'woff', 'woff2', 'ttf', 'otf', 'eot',
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'avi', 'mov',
'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'wasm',
]);
function isTextFile(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() || '';
return !BINARY_EXTENSIONS.has(ext);
}
function filePlaceholder(filePath: string): string {
const dir = filePath.split('/')[0];
const fname = filePath.split('/').pop() || '';
if (dir === 'scripts') {
return `#!/usr/bin/env bash\n# Executable code that Claude runs via hooks or tool calls.\n# Example: a linter wrapper, a code generator, a deploy helper.\n\necho "Running ${fname}..."`;
}
if (dir === 'references') {
return `# ${fname}\n\nReference documentation that Claude reads for context.\nPut API docs, style guides, architecture notes, or\nany material Claude should consult while using this skill.`;
}
if (dir === 'assets') {
return `# ${fname}\n\nStatic resources: templates, config files, schemas,\nor any files the skill copies into the project.\nExample: a .eslintrc template, a Dockerfile, a JSON schema.`;
}
return `Write ${fname} content...`;
}
const props = defineProps<{
resourceType: string;
slug: string;
}>();
const fileList = ref<FileEntry[]>([]);
const addMode = ref<'' | 'create' | 'upload'>('');
const dir = ref('scripts');
const newFileName = ref('');
const selectedFile = ref<File | null>(null);
const saving = ref(false);
const uploadError = ref('');
const createError = ref('');
const fileInput = ref<HTMLInputElement>();
// Inline editing state
const expanded = reactive<Record<string, boolean>>({});
const fileContents = reactive<Record<string, string>>({});
const savingFile = ref('');
const fileSaveStatus = reactive<Record<string, string>>({});
const apiBase = `/api/resources/${props.resourceType}/${props.slug}/files`;
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getAuthHeaders(): Record<string, string> {
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('grimoired-token') || '' : '';
if (token) return { 'Authorization': `Bearer ${token}` };
return {};
}
async function loadFiles() {
try {
const res = await fetch(apiBase);
if (res.ok) {
const data = await res.json();
fileList.value = data.files || [];
}
} catch { /* ignore */ }
}
async function toggleExpand(path: string) {
if (expanded[path]) {
expanded[path] = false;
return;
}
expanded[path] = true;
// Load content if not yet loaded
if (fileContents[path] === undefined) {
try {
const res = await fetch(`${apiBase}/${path}`);
if (res.ok) {
fileContents[path] = await res.text();
} else {
fileContents[path] = '';
}
} catch {
fileContents[path] = '';
}
}
}
async function saveFileContent(path: string) {
savingFile.value = path;
delete fileSaveStatus[path];
try {
const body = new Blob([fileContents[path]], { type: 'text/plain' });
const res = await fetch(`${apiBase}/${path}`, {
method: 'PUT',
headers: getAuthHeaders(),
body,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Save failed');
}
fileSaveStatus[path] = 'saved';
setTimeout(() => { delete fileSaveStatus[path]; }, 2000);
await loadFiles();
} catch (err) {
fileSaveStatus[path] = err instanceof Error ? err.message : 'Save failed';
} finally {
savingFile.value = '';
}
}
async function createInlineFile() {
const fname = newFileName.value.trim();
if (!fname) return;
saving.value = true;
createError.value = '';
const path = `${dir.value}/${fname}`;
try {
const body = new Blob([''], { type: 'text/plain' });
const res = await fetch(`${apiBase}/${path}`, {
method: 'PUT',
headers: getAuthHeaders(),
body,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Create failed');
}
newFileName.value = '';
addMode.value = '';
await loadFiles();
// Auto-expand the new file
fileContents[path] = '';
expanded[path] = true;
} catch (err) {
createError.value = err instanceof Error ? err.message : 'Create failed';
} finally {
saving.value = false;
}
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
selectedFile.value = input.files?.[0] || null;
}
async function uploadFile() {
if (!selectedFile.value) return;
saving.value = true;
uploadError.value = '';
try {
const formData = new FormData();
formData.append('file', selectedFile.value);
formData.append('path', `${dir.value}/${selectedFile.value.name}`);
const res = await fetch(apiBase, {
method: 'POST',
headers: getAuthHeaders(),
body: formData,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Upload failed');
}
selectedFile.value = null;
if (fileInput.value) fileInput.value.value = '';
addMode.value = '';
await loadFiles();
} catch (err) {
uploadError.value = err instanceof Error ? err.message : 'Upload failed';
} finally {
saving.value = false;
}
}
async function deleteFile(relativePath: string) {
if (!confirm(`Delete ${relativePath}?`)) return;
try {
const res = await fetch(`${apiBase}/${relativePath}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Delete failed');
}
delete expanded[relativePath];
delete fileContents[relativePath];
await loadFiles();
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
}
onMounted(loadFiles);
</script>

View File

@@ -0,0 +1,98 @@
---
interface FileEntry {
relativePath: string;
size: number;
}
interface Props {
files: FileEntry[];
slug: string;
type: string;
mainFileName: string;
mainFileSize: number;
}
const { files, slug, type, mainFileName, mainFileSize } = Astro.props;
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const DIR_HINTS: Record<string, string> = {
scripts: 'executable code',
references: 'docs & context',
assets: 'templates & files',
};
// Group files by top-level directory
const groups: Record<string, FileEntry[]> = {};
for (const f of files) {
const dir = f.relativePath.split('/')[0];
if (!groups[dir]) groups[dir] = [];
groups[dir].push(f);
}
---
<div class="space-y-2">
<!-- Main file -->
<button data-file-main class="tree-item active flex items-center gap-2 py-1 text-sm w-full text-left rounded-md px-1.5 -mx-1.5">
<svg class="h-4 w-4 shrink-0 text-[var(--color-accent-500)]" 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.25m2.25 0H5.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>
<span class="font-medium text-[var(--color-accent-400)]">{mainFileName}</span>
<span class="text-xs text-gray-600">{formatSize(mainFileSize)}</span>
<span class="rounded px-1.5 py-0.5 text-[10px] font-medium bg-[var(--color-accent-500)]/10 text-[var(--color-accent-500)]">main</span>
</button>
<!-- Subdirectory groups -->
{Object.entries(groups).map(([dir, entries]) => (
<details open class="group">
<summary class="flex items-center gap-2 cursor-pointer select-none text-sm text-gray-400 hover:text-gray-200 transition-colors py-1">
<svg class="h-4 w-4 text-gray-600 group-open:rotate-90 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
<svg class="h-4 w-4 text-[var(--color-accent-500)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
</svg>
<span class="font-medium">{dir}/</span>
{DIR_HINTS[dir] && <span class="text-xs text-gray-600">— {DIR_HINTS[dir]}</span>}
<span class="text-xs text-gray-600 ml-auto">{entries.length} file{entries.length !== 1 ? 's' : ''}</span>
</summary>
<ul class="ml-6 mt-1 space-y-0.5 border-l border-white/[0.06] pl-3">
{entries.map((f) => {
const fileName = f.relativePath.split('/').slice(1).join('/');
const downloadUrl = `/api/resources/${type}/${slug}/files/${f.relativePath}`;
return (
<li>
<button data-file-path={f.relativePath} class="tree-item flex items-center gap-2 py-0.5 text-sm w-full text-left rounded-md px-1.5 -mx-1.5">
<svg class="h-3.5 w-3.5 shrink-0 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.25m2.25 0H5.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>
<span class="text-gray-400 truncate" title={f.relativePath}>{fileName}</span>
<span class="shrink-0 text-xs text-gray-600">{formatSize(f.size)}</span>
</button>
</li>
);
})}
</ul>
</details>
))}
</div>
<style>
.tree-item {
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tree-item:hover {
background: rgba(255,255,255,0.04);
}
.tree-item.active {
background: rgba(255,255,255,0.06);
}
.tree-item.active span:first-of-type {
color: var(--color-accent-400);
}
</style>

View File

@@ -0,0 +1,106 @@
---
interface Props {
resourceType: string;
slug: string;
name: string;
description: string;
tags?: string[];
author?: string;
forkCount?: number;
downloads?: number;
pushes?: number;
lastPushedAt?: string | null;
typeLabel: string;
typeColor: string;
/** For skills: allowed tools badges */
tools?: string[];
}
const {
resourceType,
slug,
name,
description,
tags = [],
author,
forkCount = 0,
downloads = 0,
pushes = 0,
lastPushedAt,
typeLabel,
typeColor,
tools = [],
} = Astro.props;
const updatedLabel = lastPushedAt ? new Date(lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : null;
const truncated = description.length > 120 ? description.slice(0, 120) + '...' : description;
---
<a
href={`/${resourceType}/${slug}`}
class="group relative block rounded-2xl border border-white/[0.06] bg-surface-100 p-6 hover:border-accent-500/30 hover:bg-surface-200/80 transition-all duration-300"
>
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-accent-500/[0.03] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div class="relative">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" style={`background: ${typeColor}20; color: ${typeColor}; border: 1px solid ${typeColor}40;`}>
{typeLabel}
</span>
<h2 class="text-[15px] font-semibold text-white group-hover:text-accent-400 transition-colors">{name}</h2>
</div>
<svg class="h-4 w-4 text-gray-600 group-hover:text-accent-500 group-hover:translate-x-0.5 transition-all shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
{truncated && <p class="text-sm text-gray-500 leading-relaxed mb-3">{truncated}</p>}
{tools.length > 0 && (
<div class="flex flex-wrap gap-1.5 mb-3">
{tools.map((tool) => (
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2 py-0.5 text-xs font-medium text-gray-400">
{tool}
</span>
))}
</div>
)}
{tags.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span class="rounded-full bg-[var(--color-accent-500)]/10 border border-[var(--color-accent-500)]/20 px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-accent-400)]">
{tag}
</span>
))}
</div>
)}
<div class="flex items-center gap-3 mt-3">
{author && <p class="text-xs text-gray-600">by {author}</p>}
{forkCount > 0 && (
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0-12.814a2.25 2.25 0 1 0 0-2.186m0 2.186a2.25 2.25 0 1 0 0 2.186" />
</svg>
{forkCount}
</span>
)}
{downloads > 0 && (
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{downloads}
</span>
)}
{pushes > 0 && (
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
{pushes}
</span>
)}
{updatedLabel && (
<span class="text-[11px] text-gray-600">{updatedLabel}</span>
)}
</div>
</div>
</a>

View File

@@ -0,0 +1,732 @@
<template>
<form @submit.prevent="save" class="space-y-6">
<!-- Fork: author identity -->
<div v-if="isFork" class="rounded-xl border border-[var(--color-accent-500)]/20 bg-[var(--color-accent-500)]/5 p-4 space-y-3">
<p class="text-sm text-[var(--color-accent-400)]">Claim this fork as yours. It will stay open for editing until you push from CLI, which registers a token and locks it to you.</p>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Your Name</label>
<input
v-model="forkAuthorName"
type="text"
placeholder="Jane Doe"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
</div>
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Your Email</label>
<input
v-model="forkAuthorEmail"
type="email"
placeholder="jane@example.com"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
</div>
</div>
</div>
<!-- Fork slug warning -->
<div v-if="isFork && slugMatchesOriginal" class="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
<p class="text-sm text-amber-400">Change the <strong>name</strong> to generate a different slug. You can't save a fork with the same slug as the original.</p>
</div>
<!-- Shared fields: Name + Description -->
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Name</label>
<input
v-model="name"
type="text"
required
maxlength="64"
placeholder="My Awesome Resource"
:class="[
'w-full rounded-xl border px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-1 transition-all',
isFork && slugMatchesOriginal
? 'border-amber-500/30 bg-[var(--color-surface-100)] focus:border-amber-500/50 focus:ring-amber-500/20'
: 'border-white/[0.06] bg-[var(--color-surface-100)] focus:border-[var(--color-accent-500)]/50 focus:ring-[var(--color-accent-500)]/20'
]"
/>
<p class="mt-1.5 text-xs text-gray-600 flex justify-between">
<span>Slug: <code :class="['font-mono', isFork && slugMatchesOriginal ? 'text-amber-500' : 'text-gray-500']">{{ computedSlug }}</code></span>
<span :class="name.length > 58 ? 'text-amber-500' : ''">{{ name.length }}/64</span>
</p>
</div>
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Description</label>
<input
v-model="description"
type="text"
maxlength="200"
placeholder="Brief description of what this does"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
<p class="mt-1.5 text-xs text-gray-600 text-right" :class="description.length > 180 ? 'text-amber-500' : ''">{{ description.length }}/200</p>
</div>
</div>
<!-- Tags (shared) -->
<div class="relative">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Tags</label>
<div
class="flex flex-wrap items-center gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2 min-h-[42px] cursor-text focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all"
@click="tagInputEl?.focus()"
>
<span
v-for="(tag, i) in tags"
:key="tag"
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 border border-[var(--color-accent-500)]/25 pl-2.5 pr-1.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]"
>
{{ tag }}
<button type="button" @click.stop="removeTag(i)" class="rounded-full p-0.5 hover:bg-[var(--color-accent-500)]/30 transition-colors">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</span>
<input
ref="tagInputEl"
v-model="tagQuery"
type="text"
placeholder="Add tag..."
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
@keydown="onTagKeydown"
@focus="tagSuggestionsOpen = true"
@click="tagSuggestionsOpen = true"
@input="tagSuggestionsOpen = true"
@blur="onTagBlur"
/>
</div>
<div
v-if="tagSuggestionsOpen && tagSuggestions.length > 0"
class="absolute z-10 mt-1 w-full max-h-40 overflow-auto rounded-xl border border-white/[0.08] bg-[var(--color-surface-200)] shadow-xl"
>
<button
v-for="s in tagSuggestions"
:key="s"
type="button"
@mousedown.prevent="onTagSuggestionClick(s)"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors text-left"
>
<span v-if="isNewTag(s)" class="text-[var(--color-accent-500)] text-xs">+</span>
{{ s }}
<span v-if="isNewTag(s)" class="text-xs text-gray-600">(new)</span>
</button>
</div>
<p class="mt-1.5 text-xs text-gray-600">Type and press Enter or comma. Click suggestions to add.</p>
</div>
<!-- Format toggle (create mode only) -->
<div v-if="mode === 'create' && !isFork">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Format</label>
<div class="flex rounded-xl border border-white/[0.06] overflow-hidden w-fit">
<button
type="button"
@click="format = 'file'"
:class="['px-4 py-2 text-sm font-medium transition-all', format === 'file' ? 'bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] border-r border-white/[0.06]' : 'text-gray-500 hover:text-gray-300 border-r border-white/[0.06]']"
>Simple (.md)</button>
<button
type="button"
@click="format = 'folder'"
:class="['px-4 py-2 text-sm font-medium transition-all', format === 'folder' ? 'bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)]' : 'text-gray-500 hover:text-gray-300']"
>Folder</button>
</div>
<p class="mt-1.5 text-xs text-gray-600">
{{ format === 'file' ? 'Single markdown file.' : 'Directory with scripts/, references/, and assets/ subdirectories.' }}
</p>
</div>
<!-- Folder files manager (create mode, folder selected) -->
<div v-if="mode === 'create' && !isFork && format === 'folder'" class="rounded-xl border border-white/[0.08] bg-[var(--color-surface-50)] p-4 space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-400">Folder files</p>
<p class="text-[11px] text-gray-600 mt-0.5"><code class="text-gray-500">{{ computedSlug }}/{{ mainFileName }}</code> is generated from the body. Add scripts, docs, or assets here.</p>
</div>
<div class="flex gap-1.5">
<button
type="button"
@click="draftAddMode = draftAddMode === 'create' ? '' : 'create'"
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
draftAddMode === 'create'
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
Create
</button>
<button
type="button"
@click="draftAddMode = draftAddMode === 'upload' ? '' : 'upload'"
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
draftAddMode === 'upload'
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
Upload
</button>
</div>
</div>
<!-- Create inline file -->
<div v-if="draftAddMode === 'create'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3 space-y-3">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
<select v-model="draftDir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
<option value="scripts">scripts/ — executable code</option>
<option value="references">references/ — docs &amp; context</option>
<option value="assets">assets/ — templates &amp; files</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-500 mb-1">File name</label>
<input
v-model="draftFileName"
type="text"
placeholder="run.sh"
class="w-full rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm font-mono text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none transition-all"
@keydown.enter.prevent="addDraftInline"
/>
</div>
<button type="button" @click="addDraftInline" :disabled="!draftFileName.trim()" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all">Add</button>
</div>
<p class="text-[11px] text-gray-600"><strong class="text-gray-500">scripts/</strong> run via hooks or tool calls &middot; <strong class="text-gray-500">references/</strong> Claude reads as context &middot; <strong class="text-gray-500">assets/</strong> copied into the project</p>
</div>
<!-- Upload file -->
<div v-if="draftAddMode === 'upload'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
<select v-model="draftDir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
<option value="scripts">scripts/ — executable code</option>
<option value="references">references/ — docs &amp; context</option>
<option value="assets">assets/ — templates &amp; files</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-500 mb-1">File</label>
<input
type="file"
@change="addDraftUpload"
class="w-full text-sm text-gray-400 file:mr-2 file:rounded-lg file:border-0 file:bg-white/[0.06] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-gray-300 hover:file:bg-white/[0.1] file:cursor-pointer file:transition-all"
/>
</div>
</div>
<p class="text-[11px] text-gray-600 mt-2">Text files (.sh, .md, .py...) become editable inline. Binary files (images, fonts) are stored as-is.</p>
</div>
<!-- Files list -->
<div v-if="draftFiles.length > 0" class="space-y-2">
<div v-for="(f, idx) in draftFiles" :key="f.path" class="rounded-lg border border-white/[0.06] overflow-hidden">
<!-- File header -->
<div class="flex items-center gap-2 px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors group">
<button v-if="f.kind === 'inline'" type="button" @click="f.expanded = !f.expanded" class="shrink-0 text-gray-600 hover:text-gray-400 transition-colors">
<svg :class="['h-3.5 w-3.5 transition-transform', f.expanded ? 'rotate-90' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
<svg v-else class="h-3.5 w-3.5 shrink-0 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.25m2.25 0H5.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>
<span class="flex-1 text-sm text-gray-400 truncate font-mono">{{ f.path }}</span>
<span v-if="f.kind === 'inline'" class="shrink-0 text-[10px] text-gray-600 tabular-nums">{{ f.content.split('\n').length }} lines</span>
<span v-else class="shrink-0 text-xs text-gray-600">{{ formatSize(f.size) }}</span>
<span class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" :class="f.kind === 'inline' ? 'bg-[var(--color-accent-500)]/10 text-[var(--color-accent-500)]' : 'bg-white/[0.06] text-gray-500'">{{ f.kind === 'inline' ? 'text' : 'binary' }}</span>
<button
type="button"
@click="removeDraftFile(idx)"
class="shrink-0 opacity-0 group-hover:opacity-100 rounded p-1 text-gray-600 hover:text-red-400 hover:bg-red-400/10 transition-all"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
<!-- Inline editor -->
<div v-if="f.kind === 'inline' && f.expanded" class="border-t border-white/[0.06]">
<textarea
v-model="f.content"
rows="8"
:placeholder="filePlaceholder(f.path)"
class="w-full bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-600 focus:outline-none resize-y leading-relaxed"
/>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-600">No extra files yet. Create scripts, docs, or upload assets.</p>
</div>
<!-- Format badge (edit mode) -->
<div v-if="mode === 'edit' && initialFormat === 'folder'" class="flex items-center gap-2">
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-medium bg-white/[0.06] text-gray-400 border border-white/[0.06]">folder</span>
<span class="text-xs text-gray-600">Folder resource. Manage sub-files below.</span>
</div>
<!-- Type-specific fields -->
<template v-for="field in typeFields" :key="field.key">
<!-- Group toggles on same row -->
<template v-if="field.type === 'toggle'">
<!-- Toggles are grouped below -->
</template>
<FieldRenderer
v-else
:field="field"
:modelValue="fieldValues[field.key]"
@update:modelValue="fieldValues[field.key] = $event"
:tools="availableTools"
:models="availableModels"
:skills="availableSkills"
/>
</template>
<!-- Toggle fields grouped in a row -->
<div v-if="toggleFields.length > 0" class="flex flex-wrap gap-6">
<FieldRenderer
v-for="field in toggleFields"
:key="field.key"
:field="field"
:modelValue="fieldValues[field.key]"
@update:modelValue="fieldValues[field.key] = $event"
/>
</div>
<!-- Body + Preview -->
<div class="grid gap-4 lg:grid-cols-2">
<div>
<label class="flex justify-between text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">
<span>Body</span>
<span :class="bodyLines > 400 ? 'text-amber-500' : ''">{{ bodyLines }}/500 lines</span>
</label>
<textarea
v-model="body"
rows="20"
:placeholder="bodyPlaceholder"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-3 font-mono text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all resize-y leading-relaxed"
/>
</div>
<div>
<p class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Preview</p>
<div
class="skill-prose rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] p-5 min-h-[20rem] overflow-auto"
v-html="previewHtml"
/>
</div>
</div>
<!-- File Manager (edit mode, folder format) -->
<FileManager
v-if="mode === 'edit' && initialFormat === 'folder' && slug"
:resourceType="resourceType"
:slug="slug"
/>
<div class="flex items-center gap-4 pt-2">
<button
type="submit"
:disabled="saving || (isFork && slugMatchesOriginal)"
:title="isFork && slugMatchesOriginal ? 'Change the name to generate a different slug' : ''"
class="inline-flex items-center gap-2 rounded-xl bg-[var(--color-accent-500)] px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-[var(--color-accent-500)]/20 hover:bg-[var(--color-accent-600)] hover:shadow-[var(--color-accent-500)]/30 disabled:opacity-50 active:scale-[0.97] transition-all"
>
<svg v-if="saving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ saving ? 'Saving...' : (isFork ? 'Create Fork' : (mode === 'create' ? `Create ${typeSingular}` : 'Save Changes')) }}
</button>
<a href="/" class="text-sm text-gray-600 hover:text-gray-300 transition-colors">Cancel</a>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue';
import { marked } from 'marked';
import FieldRenderer from './FieldRenderer.vue';
import FileManager from './FileManager.vue';
interface FieldDef {
key: string;
label: string;
type: 'text' | 'select' | 'toggle-grid' | 'toggle' | 'number' | 'json' | 'tags';
placeholder?: string;
hint?: string;
options?: Array<{ value: string; label: string }>;
dynamicOptions?: 'tools' | 'models';
defaultValue?: unknown;
}
const props = defineProps<{
resourceType: string;
typeSingular: string;
typeFields: FieldDef[];
mode: 'create' | 'edit';
slug?: string;
forkOf?: string;
initialName?: string;
initialDescription?: string;
initialTags?: string;
initialBody?: string;
initialAuthor?: string;
initialAuthorEmail?: string;
initialFormat?: 'file' | 'folder';
/** JSON-serialized initial field values for type-specific fields */
initialFieldValues?: string;
availableTools?: string[];
availableModels?: Array<{ id: string; display_name: string }>;
availableSkills?: string[];
availableTags?: string;
}>();
const isFork = computed(() => Boolean(props.forkOf));
const name = ref(props.initialName || '');
const description = ref(props.initialDescription || '');
const body = ref(props.initialBody || '');
const saving = ref(false);
const error = ref('');
const format = ref<'file' | 'folder'>(props.initialFormat || 'file');
// Draft files for folder creation (held in memory until save)
interface DraftFile {
path: string;
kind: 'inline' | 'upload';
content: string; // text content for inline files
file: File | null; // binary for uploads
size: number;
expanded: boolean; // UI: is the editor expanded
}
const draftFiles = ref<DraftFile[]>([]);
const draftDir = ref('scripts');
const draftFileName = ref('');
const draftAddMode = ref<'' | 'create' | 'upload'>('');
const TEXT_EXTENSIONS = new Set([
'md', 'txt', 'sh', 'bash', 'zsh', 'py', 'js', 'ts', 'json', 'yaml', 'yml',
'toml', 'xml', 'html', 'css', 'sql', 'rb', 'go', 'rs', 'lua', 'conf', 'cfg', 'ini',
]);
function isTextFile(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() || '';
return TEXT_EXTENSIONS.has(ext);
}
function addDraftInline() {
const fname = draftFileName.value.trim();
if (!fname) return;
const path = `${draftDir.value}/${fname}`;
// Replace if same path
draftFiles.value = draftFiles.value.filter(f => f.path !== path);
draftFiles.value.push({ path, kind: 'inline', content: '', file: null, size: 0, expanded: true });
draftFileName.value = '';
draftAddMode.value = '';
}
function addDraftUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const path = `${draftDir.value}/${file.name}`;
draftFiles.value = draftFiles.value.filter(f => f.path !== path);
if (isTextFile(file.name)) {
// Read as text so it's editable
const reader = new FileReader();
reader.onload = () => {
draftFiles.value.push({
path, kind: 'inline', content: reader.result as string, file: null,
size: file.size, expanded: false,
});
};
reader.readAsText(file);
} else {
draftFiles.value.push({ path, kind: 'upload', content: '', file, size: file.size, expanded: false });
}
input.value = '';
draftAddMode.value = '';
}
function removeDraftFile(idx: number) {
draftFiles.value.splice(idx, 1);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function draftFileToBlob(df: DraftFile): Blob {
if (df.kind === 'upload' && df.file) return df.file;
return new Blob([df.content], { type: 'text/plain' });
}
function filePlaceholder(filePath: string): string {
const dir = filePath.split('/')[0];
const fname = filePath.split('/').pop() || '';
if (dir === 'scripts') {
return `#!/usr/bin/env bash\n# Executable code that Claude runs via hooks or tool calls.\n# Example: a linter wrapper, a code generator, a deploy helper.\n\necho "Running ${fname}..."`;
}
if (dir === 'references') {
return `# ${fname}\n\nReference documentation that Claude reads for context.\nPut API docs, style guides, architecture notes, or\nany material Claude should consult while using this skill.`;
}
if (dir === 'assets') {
return `# ${fname}\n\nStatic resources: templates, config files, schemas,\nor any files the skill copies into the project.\nExample: a .eslintrc template, a Dockerfile, a JSON schema.`;
}
return `Write ${fname} content...`;
}
// Fork author fields
const forkAuthorName = ref('');
const forkAuthorEmail = ref('');
// Load token from localStorage
const authorToken = ref(
typeof localStorage !== 'undefined'
? localStorage.getItem('grimoired-token') || ''
: ''
);
// Tags
const tags = ref<string[]>(
props.initialTags
? props.initialTags.split(',').map(t => t.trim()).filter(Boolean)
: []
);
const tagQuery = ref('');
const tagSuggestionsOpen = ref(false);
const tagInputEl = ref<HTMLInputElement>();
const knownTags = props.availableTags
? props.availableTags.split(',').map(t => t.trim()).filter(Boolean)
: [];
const tagSuggestions = computed(() => {
const q = tagQuery.value.toLowerCase().trim();
const current = new Set(tags.value.map(t => t.toLowerCase()));
const matches = knownTags.filter(t => !current.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q)));
if (q && !current.has(q) && !matches.some(m => m.toLowerCase() === q)) {
matches.push(q);
}
return matches;
});
const isNewTag = (tag: string) => !knownTags.some(t => t.toLowerCase() === tag.toLowerCase());
function addTag(tag: string) {
const normalized = tag.trim().toLowerCase();
if (normalized && !tags.value.some(t => t.toLowerCase() === normalized)) {
tags.value.push(normalized);
}
tagQuery.value = '';
tagInputEl.value?.focus();
}
function removeTag(idx: number) {
tags.value.splice(idx, 1);
}
function onTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (tagQuery.value.trim()) addTag(tagQuery.value);
} else if (e.key === 'Backspace' && !tagQuery.value && tags.value.length) {
tags.value.pop();
}
}
let tagBlurTimer: ReturnType<typeof setTimeout>;
function onTagBlur() {
tagBlurTimer = setTimeout(() => { tagSuggestionsOpen.value = false; }, 200);
}
function onTagSuggestionClick(tag: string) {
clearTimeout(tagBlurTimer);
addTag(tag);
}
// Type-specific field values
const parsedInitial = props.initialFieldValues ? JSON.parse(props.initialFieldValues) : {};
const fieldValues = reactive<Record<string, unknown>>({});
for (const field of props.typeFields) {
fieldValues[field.key] = parsedInitial[field.key] ?? field.defaultValue ?? (
field.type === 'toggle' ? false :
field.type === 'toggle-grid' ? [] :
field.type === 'tags' ? [] :
''
);
}
// Separate toggles from other fields for layout grouping
const toggleFields = computed(() => props.typeFields.filter(f => f.type === 'toggle'));
const mainFileName = computed(() => {
const map: Record<string, string> = {
skills: 'SKILL.md',
agents: 'AGENT.md',
'output-styles': 'OUTPUT-STYLE.md',
rules: 'RULE.md',
};
return map[props.resourceType] || 'MAIN.md';
});
const bodyPlaceholder = computed(() => {
const placeholders: Record<string, string> = {
skills: '# My Skill\n\nInstructions for Claude...',
agents: '# My Agent\n\nAgent system prompt...',
'output-styles': '# Output Style\n\nFormatting instructions...',
rules: '# Rule\n\nRule content...',
};
return placeholders[props.resourceType] || '# Content\n\nInstructions...';
});
// Slug
const computedSlug = computed(() => {
if (props.mode === 'edit' && props.slug) return props.slug;
return name.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64) || 'my-resource';
});
const slugMatchesOriginal = computed(() => {
if (!props.forkOf) return false;
return computedSlug.value === props.forkOf;
});
const bodyLines = computed(() => body.value.split('\n').length);
// Preview
let previewHtml = ref('');
let debounceTimer: ReturnType<typeof setTimeout>;
watch(body, (val) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
previewHtml.value = await marked(val || '');
}, 300);
}, { immediate: true });
// Build frontmatter content
function buildContent(): string {
const lines: string[] = ['---'];
lines.push(`name: ${name.value}`);
if (description.value) lines.push(`description: ${description.value}`);
if (isFork.value) {
if (forkAuthorName.value) lines.push(`author: ${forkAuthorName.value}`);
if (forkAuthorEmail.value) lines.push(`author-email: ${forkAuthorEmail.value}`);
lines.push(`fork-of: ${props.forkOf}`);
} else {
if (props.initialAuthor) lines.push(`author: ${props.initialAuthor}`);
if (props.initialAuthorEmail) lines.push(`author-email: ${props.initialAuthorEmail}`);
}
if (tags.value.length > 0) lines.push(`tags: ${tags.value.join(', ')}`);
// Type-specific fields
for (const field of props.typeFields) {
const val = fieldValues[field.key];
if (field.type === 'toggle') {
// Only write non-default values
const def = field.defaultValue ?? false;
if (val !== def) {
lines.push(`${field.key}: ${val}`);
}
} else if (field.type === 'toggle-grid') {
const arr = Array.isArray(val) ? val : [];
if (arr.length > 0) lines.push(`${field.key}: ${arr.join(', ')}`);
} else if (field.type === 'tags') {
const arr = Array.isArray(val) ? val : [];
if (arr.length > 0) lines.push(`${field.key}: ${arr.join(', ')}`);
} else if (field.type === 'json') {
const str = typeof val === 'string' ? val.trim() : '';
if (str) {
try {
const parsed = JSON.parse(str);
lines.push(`${field.key}: ${JSON.stringify(parsed)}`);
} catch { /* skip invalid JSON */ }
}
} else if (field.type === 'number') {
const num = typeof val === 'string' ? val.trim() : String(val || '');
if (num) lines.push(`${field.key}: ${num}`);
} else {
// text, select
const str = typeof val === 'string' ? val : '';
if (str) lines.push(`${field.key}: ${str}`);
}
}
lines.push('---');
return lines.join('\n') + '\n\n' + body.value.trim() + '\n';
}
async function save() {
saving.value = true;
error.value = '';
try {
const content = buildContent();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (!isFork.value && authorToken.value) {
headers['Authorization'] = `Bearer ${authorToken.value}`;
}
const apiBase = `/api/resources/${props.resourceType}`;
if (props.mode === 'create') {
const res = await fetch(apiBase, {
method: 'POST',
headers,
body: JSON.stringify({ slug: computedSlug.value, content, format: format.value }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `Failed to create ${props.typeSingular.toLowerCase()}`);
}
// Upload draft files if folder format
if (format.value === 'folder' && draftFiles.value.length > 0) {
const filesApi = `/api/resources/${props.resourceType}/${computedSlug.value}/files`;
const uploadHeaders: Record<string, string> = {};
if (authorToken.value) uploadHeaders['Authorization'] = `Bearer ${authorToken.value}`;
for (const df of draftFiles.value) {
const formData = new FormData();
const blob = draftFileToBlob(df);
formData.append('file', blob, df.path.split('/').pop());
formData.append('path', df.path);
await fetch(filesApi, { method: 'POST', headers: uploadHeaders, body: formData });
}
}
window.location.href = `/${props.resourceType}/${computedSlug.value}`;
} else {
const res = await fetch(`${apiBase}/${props.slug}`, {
method: 'PUT',
headers,
body: JSON.stringify({ content }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `Failed to update ${props.typeSingular.toLowerCase()}`);
}
window.location.href = `/${props.resourceType}/${props.slug}`;
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong';
} finally {
saving.value = false;
}
}
</script>

View File

@@ -0,0 +1,402 @@
<template>
<div class="mb-6 space-y-4">
<!-- Type tabs -->
<div class="flex items-center gap-1 rounded-xl border border-white/[0.06] p-1 w-fit">
<button
v-for="tab in typeTabs"
:key="tab.value"
@click="setTypeFilter(tab.value)"
:class="[
'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
typeFilter === tab.value
? 'bg-white/[0.08] text-white'
: 'text-gray-500 hover:text-gray-300 hover:bg-white/[0.03]'
]"
>
{{ tab.label }}
<span class="ml-1 text-xs text-gray-600">{{ tab.count }}</span>
</button>
</div>
<!-- Filters row -->
<div class="flex flex-wrap items-end gap-3">
<!-- Text search -->
<div class="w-full sm:w-auto sm:flex-1 sm:min-w-[180px]">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Search</label>
<div class="relative">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
v-model="query"
type="text"
placeholder="Name, description, tools..."
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
</div>
</div>
<!-- Author filter -->
<div class="w-[calc(50%-6px)] sm:w-auto sm:flex-1 sm:min-w-[160px] relative">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Authors</label>
<div class="flex flex-wrap items-center gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2 min-h-[42px] focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all">
<span
v-for="a in selectedAuthors"
:key="a"
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] px-2.5 py-0.5 text-xs font-medium"
>
{{ a }}
<button @click="removeAuthor(a)" class="hover:text-white transition-colors">&times;</button>
</span>
<input
ref="authorInputEl"
v-model="authorQuery"
type="text"
:placeholder="selectedAuthors.length ? '' : 'Filter by author...'"
@focus="authorOpen = true"
@click="authorOpen = true"
@input="authorOpen = true"
@blur="onAuthorBlur"
@keydown.enter.prevent="onAuthorEnter"
@keydown.backspace="onAuthorBackspace"
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
/>
</div>
<div
v-if="authorOpen && authorSuggestions.length > 0"
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] shadow-xl"
>
<button
v-for="a in authorSuggestions"
:key="a"
@mousedown.prevent="addAuthor(a)"
class="block w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-white/[0.06] hover:text-white transition-colors"
>
{{ a }}
</button>
</div>
</div>
<!-- Tag filter -->
<div class="w-[calc(50%-6px)] sm:w-auto sm:flex-1 sm:min-w-[160px] relative">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Tags</label>
<div class="flex flex-wrap items-center gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2 min-h-[42px] focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all">
<span
v-for="t in selectedTags"
:key="t"
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] px-2.5 py-0.5 text-xs font-medium"
>
{{ t }}
<button @click="removeTag(t)" class="hover:text-white transition-colors">&times;</button>
</span>
<input
ref="tagInputEl"
v-model="tagQuery"
type="text"
:placeholder="selectedTags.length ? '' : 'Filter by tag...'"
@focus="tagOpen = true"
@click="tagOpen = true"
@input="tagOpen = true"
@blur="onTagBlur"
@keydown.enter.prevent="onTagEnter"
@keydown.backspace="onTagBackspace"
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
/>
</div>
<div
v-if="tagOpen && tagSuggestions.length > 0"
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] shadow-xl"
>
<button
v-for="t in tagSuggestions"
:key="t"
@mousedown.prevent="addTag(t)"
class="block w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-white/[0.06] hover:text-white transition-colors"
>
{{ t }}
</button>
</div>
</div>
<!-- Reset -->
<button
v-if="hasActiveFilters"
@click="reset"
class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-gray-500 hover:text-white hover:bg-white/[0.06] transition-all"
>
Clear
</button>
<!-- View toggle -->
<div class="flex rounded-xl border border-white/[0.06] overflow-hidden">
<button
@click="setView('grid')"
:class="['px-3 py-2.5 transition-all', viewMode === 'grid' ? 'bg-white/[0.08] text-white' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.03]']"
title="Grid view"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
</button>
<button
@click="setView('table')"
:class="['px-3 py-2.5 transition-all', viewMode === 'table' ? 'bg-white/[0.08] text-white' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.03]']"
title="Table view"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
</svg>
</button>
</div>
</div>
<!-- Pagination controls -->
<div v-if="totalPages > 1" class="flex flex-wrap items-center justify-between gap-3">
<span class="text-sm text-gray-500">
Showing {{ rangeStart }}{{ rangeEnd }} of {{ filteredCount }}
</span>
<div class="flex items-center gap-1">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
class="rounded-lg border border-white/[0.06] px-3 py-1.5 text-sm transition-all disabled:opacity-30 disabled:cursor-not-allowed text-gray-400 hover:text-white hover:bg-white/[0.06]"
>
Prev
</button>
<button
v-for="p in visiblePages"
:key="p"
@click="goToPage(p)"
:class="['rounded-lg px-3 py-1.5 text-sm transition-all', p === currentPage ? 'bg-[var(--color-accent-500)] text-white font-medium' : 'border border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.06]']"
>
{{ p }}
</button>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="rounded-lg border border-white/[0.06] px-3 py-1.5 text-sm transition-all disabled:opacity-30 disabled:cursor-not-allowed text-gray-400 hover:text-white hover:bg-white/[0.06]"
>
Next
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue';
const props = defineProps<{
authors?: string;
tags?: string;
totalCount?: number;
typeCounts?: string; // JSON: { skills: N, agents: N, ... }
}>();
const authorList = props.authors
? props.authors.split(',').map(a => a.trim()).filter(Boolean)
: [];
const tagList = props.tags
? props.tags.split(',').map(t => t.trim()).filter(Boolean)
: [];
const counts = props.typeCounts ? JSON.parse(props.typeCounts) as Record<string, number> : {};
const totalAll = props.totalCount || 0;
const typeTabs = computed(() => [
{ value: '', label: 'All', count: totalAll },
{ value: 'skills', label: 'Skills', count: counts.skills || 0 },
{ value: 'agents', label: 'Agents', count: counts.agents || 0 },
{ value: 'output-styles', label: 'Output Styles', count: counts['output-styles'] || 0 },
{ value: 'rules', label: 'Rules', count: counts.rules || 0 },
]);
const query = ref('');
const typeFilter = ref('');
const viewMode = ref<'grid' | 'table'>('grid');
const currentPage = ref(1);
const filteredCount = ref(totalAll);
const PER_PAGE_GRID = 12;
const PER_PAGE_TABLE = 20;
const perPage = computed(() => viewMode.value === 'grid' ? PER_PAGE_GRID : PER_PAGE_TABLE);
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCount.value / perPage.value)));
const rangeStart = computed(() => filteredCount.value === 0 ? 0 : (currentPage.value - 1) * perPage.value + 1);
const rangeEnd = computed(() => Math.min(currentPage.value * perPage.value, filteredCount.value));
const visiblePages = computed(() => {
const pages: number[] = [];
const total = totalPages.value;
const cur = currentPage.value;
const maxVisible = 7;
if (total <= maxVisible) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
const half = Math.floor(maxVisible / 2);
let start = Math.max(1, cur - half);
let end = start + maxVisible - 1;
if (end > total) {
end = total;
start = end - maxVisible + 1;
}
for (let i = start; i <= end; i++) pages.push(i);
}
return pages;
});
// --- Author pills ---
const selectedAuthors = ref<string[]>([]);
const authorQuery = ref('');
const authorOpen = ref(false);
const authorInputEl = ref<HTMLInputElement>();
const authorSuggestions = computed(() => {
const q = authorQuery.value.toLowerCase().trim();
const selected = new Set(selectedAuthors.value.map(a => a.toLowerCase()));
return authorList.filter(a => !selected.has(a.toLowerCase()) && (!q || a.toLowerCase().includes(q)));
});
let authorBlurTimer: ReturnType<typeof setTimeout>;
function onAuthorBlur() { authorBlurTimer = setTimeout(() => { authorOpen.value = false; }, 200); }
function addAuthor(author: string) {
clearTimeout(authorBlurTimer);
if (!selectedAuthors.value.some(a => a.toLowerCase() === author.toLowerCase())) {
selectedAuthors.value.push(author);
}
authorQuery.value = '';
authorInputEl.value?.focus();
}
function removeAuthor(author: string) { selectedAuthors.value = selectedAuthors.value.filter(a => a !== author); }
function onAuthorEnter() { if (authorSuggestions.value.length > 0) addAuthor(authorSuggestions.value[0]); }
function onAuthorBackspace() { if (!authorQuery.value && selectedAuthors.value.length > 0) selectedAuthors.value.pop(); }
// --- Tag pills ---
const selectedTags = ref<string[]>([]);
const tagQuery = ref('');
const tagOpen = ref(false);
const tagInputEl = ref<HTMLInputElement>();
const tagSuggestions = computed(() => {
const q = tagQuery.value.toLowerCase().trim();
const selected = new Set(selectedTags.value.map(t => t.toLowerCase()));
return tagList.filter(t => !selected.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q)));
});
let tagBlurTimer: ReturnType<typeof setTimeout>;
function onTagBlur() { tagBlurTimer = setTimeout(() => { tagOpen.value = false; }, 200); }
function addTag(tag: string) {
clearTimeout(tagBlurTimer);
if (!selectedTags.value.some(t => t.toLowerCase() === tag.toLowerCase())) {
selectedTags.value.push(tag);
}
tagQuery.value = '';
tagInputEl.value?.focus();
}
function removeTag(tag: string) { selectedTags.value = selectedTags.value.filter(t => t !== tag); }
function onTagEnter() { if (tagSuggestions.value.length > 0) addTag(tagSuggestions.value[0]); }
function onTagBackspace() { if (!tagQuery.value && selectedTags.value.length > 0) selectedTags.value.pop(); }
// --- View toggle ---
function setView(mode: 'grid' | 'table') {
viewMode.value = mode;
localStorage.setItem('skillsViewMode', mode);
const grid = document.getElementById('resources-grid');
const table = document.getElementById('resources-table');
if (grid && table) {
grid.classList.toggle('hidden', mode !== 'grid');
table.classList.toggle('hidden', mode !== 'table');
}
currentPage.value = 1;
nextTick(() => applyFilters());
}
function setTypeFilter(type: string) {
typeFilter.value = type;
currentPage.value = 1;
}
onMounted(() => {
const saved = localStorage.getItem('skillsViewMode') as 'grid' | 'table' | null;
if (saved === 'table') setView('table');
});
// --- Filtering + Pagination ---
const hasActiveFilters = computed(() =>
query.value || selectedAuthors.value.length > 0 || selectedTags.value.length > 0 || typeFilter.value
);
function applyFilters() {
const q = query.value.toLowerCase().trim();
const authors = selectedAuthors.value.map(a => a.toLowerCase());
const tags = selectedTags.value.map(t => t.toLowerCase());
const tf = typeFilter.value;
const activeId = viewMode.value === 'grid' ? 'resources-grid' : 'resources-table';
const inactiveId = viewMode.value === 'grid' ? 'resources-table' : 'resources-grid';
const activeItems = Array.from(document.querySelectorAll<HTMLElement>(`#${activeId} [data-resource]`));
const inactiveItems = document.querySelectorAll<HTMLElement>(`#${inactiveId} [data-resource]`);
inactiveItems.forEach(el => el.style.display = 'none');
const matching: HTMLElement[] = [];
activeItems.forEach((card) => {
const name = card.dataset.name || '';
const desc = card.dataset.description || '';
const tools = card.dataset.tools || '';
const cardAuthor = card.dataset.author || '';
const cardTags = (card.dataset.tags || '').split(',').filter(Boolean);
const cardType = card.dataset.type || '';
const matchText = !q || name.includes(q) || desc.includes(q) || tools.includes(q) || cardTags.some(t => t.includes(q));
const matchAuthor = authors.length === 0 || authors.some(a => cardAuthor.includes(a));
const matchTag = tags.length === 0 || tags.every(t => cardTags.includes(t));
const matchType = !tf || cardType === tf;
if (matchText && matchAuthor && matchTag && matchType) {
matching.push(card);
}
});
filteredCount.value = matching.length;
const maxPage = Math.max(1, Math.ceil(matching.length / perPage.value));
if (currentPage.value > maxPage) currentPage.value = maxPage;
const start = (currentPage.value - 1) * perPage.value;
const end = start + perPage.value;
activeItems.forEach((card) => {
const idx = matching.indexOf(card);
if (idx === -1) {
card.style.display = 'none';
} else if (idx >= start && idx < end) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return;
currentPage.value = page;
applyFilters();
}
function reset() {
query.value = '';
selectedAuthors.value = [];
authorQuery.value = '';
selectedTags.value = [];
tagQuery.value = '';
typeFilter.value = '';
currentPage.value = 1;
}
watch([query, selectedAuthors, selectedTags, typeFilter], () => {
currentPage.value = 1;
applyFilters();
}, { deep: true });
</script>

View File

@@ -327,7 +327,7 @@ const forkAuthorEmail = ref('');
// Load token from localStorage (set by EditGate modal)
const authorToken = ref(
typeof localStorage !== 'undefined'
? localStorage.getItem('skillshere-token') || ''
? localStorage.getItem('grimoired-token') || ''
: ''
);

View File

@@ -5,7 +5,7 @@ interface Props {
title?: string;
}
const { title = 'Skills Here' } = Astro.props;
const { title = 'Grimoired' } = Astro.props;
---
<!doctype html>
@@ -28,23 +28,58 @@ const { title = 'Skills Here' } = Astro.props;
<nav class="relative z-50 border-b border-white/[0.06] bg-surface-50/80 backdrop-blur-xl">
<div class="mx-auto max-w-6xl flex items-center justify-between px-6 py-4">
<a href="/" class="group flex items-center gap-2.5">
<img src="/favicon.svg" alt="Skills Here" class="h-8 w-8" />
<span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">Skills Here</span>
</a>
<a
href="/new"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 hover:shadow-accent-500/30 active:scale-[0.97] 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>
New Skill
<img src="/favicon.svg" alt="Grimoired" class="h-8 w-8" />
<span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">Grimoired</span>
</a>
<!-- New dropdown -->
<div class="relative" id="new-dropdown">
<button
id="new-btn"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 hover:shadow-accent-500/30 active:scale-[0.97] 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>
New
<svg class="h-3 w-3 ml-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div id="new-menu" class="hidden absolute right-0 mt-2 w-48 rounded-xl border border-white/[0.08] bg-[var(--color-surface-200)] shadow-2xl overflow-hidden z-50">
<a href="/skills/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
<span class="h-2 w-2 rounded-full" style="background: #fb923c;"></span>
New Skill
</a>
<a href="/agents/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
<span class="h-2 w-2 rounded-full" style="background: #818cf8;"></span>
New Agent
</a>
<a href="/output-styles/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
<span class="h-2 w-2 rounded-full" style="background: #34d399;"></span>
New Output Style
</a>
<a href="/rules/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
<span class="h-2 w-2 rounded-full" style="background: #f472b6;"></span>
New Rule
</a>
</div>
</div>
</div>
</nav>
<main class="relative mx-auto max-w-6xl px-6 py-10">
<slot />
</main>
<script>
// Dropdown toggle
const btn = document.getElementById('new-btn')!;
const menu = document.getElementById('new-menu')!;
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', () => menu.classList.add('hidden'));
</script>
</body>
</html>

236
src/lib/registry.ts Normal file
View File

@@ -0,0 +1,236 @@
import path from 'node:path';
export const RESOURCE_TYPES = ['skills', 'agents', 'output-styles', 'rules'] as const;
export type ResourceType = (typeof RESOURCE_TYPES)[number];
export function isValidResourceType(type: string): type is ResourceType {
return (RESOURCE_TYPES as readonly string[]).includes(type);
}
export interface FieldDef {
key: string;
label: string;
type: 'text' | 'select' | 'toggle-grid' | 'toggle' | 'number' | 'json' | 'tags';
placeholder?: string;
hint?: string;
options?: Array<{ value: string; label: string }>;
/** For toggle-grid: dynamic options fetched from external source */
dynamicOptions?: 'tools' | 'models' | 'skills';
defaultValue?: unknown;
}
export interface ResourceTypeConfig {
slug: ResourceType;
label: string;
labelSingular: string;
/** Directory inside .claude/ where Claude Code reads these */
claudeDir: string;
/** Directory on server where data is stored */
dataDir: string;
/** Color for UI badges */
color: string;
/** Main file name inside a folder-based resource */
mainFileName: string;
/** Type-specific frontmatter fields */
fields: FieldDef[];
}
/** Allowed subdirectories inside a folder-based resource */
export const FOLDER_SUBDIRS = ['scripts', 'references', 'assets'] as const;
const DATA_ROOT = process.env.DATA_DIR || 'data';
export const REGISTRY: Record<ResourceType, ResourceTypeConfig> = {
skills: {
slug: 'skills',
label: 'Skills',
labelSingular: 'Skill',
claudeDir: 'skills',
dataDir: path.resolve(process.env.SKILLS_DIR || `${DATA_ROOT}/skills`),
color: '#fb923c', // orange
mainFileName: 'SKILL.md',
fields: [
{
key: 'allowed-tools',
label: 'Allowed Tools',
type: 'toggle-grid',
dynamicOptions: 'tools',
hint: 'Select which tools this skill can use',
},
{
key: 'argument-hint',
label: 'Argument Hint',
type: 'text',
placeholder: 'e.g. <file-path>',
},
{
key: 'model',
label: 'Model',
type: 'select',
dynamicOptions: 'models',
},
{
key: 'user-invocable',
label: 'User Invocable',
type: 'toggle',
hint: 'Show in /menu',
defaultValue: true,
},
{
key: 'disable-model-invocation',
label: 'Disable Model Invocation',
type: 'toggle',
hint: 'Manual only',
defaultValue: false,
},
{
key: 'context',
label: 'Context',
type: 'select',
options: [
{ value: '', label: 'Inline (default)' },
{ value: 'fork', label: 'Fork (run in subagent)' },
],
hint: 'Fork runs the skill in an isolated subagent context',
},
{
key: 'agent',
label: 'Agent',
type: 'select',
options: [
{ value: '', label: 'general-purpose (default)' },
{ value: 'Explore', label: 'Explore' },
{ value: 'Plan', label: 'Plan' },
],
},
{
key: 'hooks',
label: 'Hooks',
type: 'json',
placeholder: '{ "preToolExecution": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo pre" }] }] }',
hint: 'JSON object. Leave empty to omit.',
},
],
},
agents: {
slug: 'agents',
label: 'Agents',
labelSingular: 'Agent',
claudeDir: 'agents',
dataDir: path.resolve(process.env.AGENTS_DIR || `${DATA_ROOT}/agents`),
color: '#818cf8', // indigo
mainFileName: 'AGENT.md',
fields: [
{
key: 'tools',
label: 'Tools',
type: 'toggle-grid',
dynamicOptions: 'tools',
hint: 'Tools this agent can use',
},
{
key: 'disallowedTools',
label: 'Disallowed Tools',
type: 'toggle-grid',
dynamicOptions: 'tools',
hint: 'Tools explicitly denied',
},
{
key: 'model',
label: 'Model',
type: 'select',
dynamicOptions: 'models',
},
{
key: 'permissionMode',
label: 'Permission Mode',
type: 'select',
options: [
{ value: '', label: 'Default' },
{ value: 'default', label: 'default' },
{ value: 'plan', label: 'plan' },
{ value: 'bypassPermissions', label: 'bypassPermissions' },
],
},
{
key: 'maxTurns',
label: 'Max Turns',
type: 'number',
placeholder: 'e.g. 10',
},
{
key: 'skills',
label: 'Preloaded Skills',
type: 'tags',
hint: 'Skill slugs to preload',
dynamicOptions: 'skills',
},
{
key: 'mcpServers',
label: 'MCP Servers',
type: 'json',
hint: 'JSON object with MCP server configs',
},
{
key: 'memory',
label: 'Memory',
type: 'select',
options: [
{ value: '', label: 'Default' },
{ value: 'true', label: 'Enabled' },
{ value: 'false', label: 'Disabled' },
],
},
{
key: 'hooks',
label: 'Hooks',
type: 'json',
placeholder: '{ "preToolExecution": [...] }',
hint: 'JSON object. Leave empty to omit.',
},
],
},
'output-styles': {
slug: 'output-styles',
label: 'Output Styles',
labelSingular: 'Output Style',
claudeDir: 'output-styles',
dataDir: path.resolve(process.env.OUTPUT_STYLES_DIR || `${DATA_ROOT}/output-styles`),
color: '#34d399', // emerald
mainFileName: 'OUTPUT-STYLE.md',
fields: [
{
key: 'keep-coding-instructions',
label: 'Keep Coding Instructions',
type: 'toggle',
hint: 'Preserve default coding behavior instructions',
defaultValue: false,
},
],
},
rules: {
slug: 'rules',
label: 'Rules',
labelSingular: 'Rule',
claudeDir: 'rules',
dataDir: path.resolve(process.env.RULES_DIR || `${DATA_ROOT}/rules`),
color: '#f472b6', // pink
mainFileName: 'RULE.md',
fields: [
{
key: 'paths',
label: 'Paths',
type: 'tags',
hint: 'Glob patterns for files this rule applies to (e.g. src/**/*.ts)',
placeholder: 'Add glob pattern...',
},
],
},
};
export function getTypeConfig(type: ResourceType): ResourceTypeConfig {
return REGISTRY[type];
}

358
src/lib/resources.ts Normal file
View File

@@ -0,0 +1,358 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { type ResourceType, getTypeConfig, RESOURCE_TYPES, FOLDER_SUBDIRS } from './registry';
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SLUG_LENGTH = 64;
export type ResourceFormat = 'file' | 'folder';
export interface ResourceFileEntry {
relativePath: string;
size: number;
}
export interface ResourceMeta {
slug: string;
resourceType: ResourceType;
name: string;
description: string;
tags: string[];
author: string;
'author-email': string;
'fork-of': string;
format: ResourceFormat;
/** All frontmatter fields (type-specific included) */
fields: Record<string, unknown>;
}
export interface Resource extends ResourceMeta {
content: string;
raw: string;
files: ResourceFileEntry[];
}
export function isValidSlug(slug: string): boolean {
return slug.length >= 2 && slug.length <= MAX_SLUG_LENGTH && SLUG_RE.test(slug);
}
function parseList(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 [];
}
function parseResource(
type: ResourceType,
slug: string,
raw: string,
format: ResourceFormat = 'file',
files: ResourceFileEntry[] = [],
): Resource {
const { data, content } = matter(raw);
const fields: Record<string, unknown> = { ...data };
return {
slug,
resourceType: type,
name: (data.name as string) || slug,
description: (data.description as string) || '',
tags: parseList(data.tags),
author: (data.author as string) || '',
'author-email': (data['author-email'] as string) || '',
'fork-of': (data['fork-of'] as string) || '',
format,
fields,
content: content.trim(),
raw,
files,
};
}
// --- Path helpers ---
function filePath(type: ResourceType, slug: string): string {
const config = getTypeConfig(type);
return path.join(config.dataDir, `${slug}.md`);
}
function folderPath(type: ResourceType, slug: string): string {
const config = getTypeConfig(type);
return path.join(config.dataDir, slug);
}
function mainFilePath(type: ResourceType, slug: string): string {
const config = getTypeConfig(type);
return path.join(config.dataDir, slug, config.mainFileName);
}
// --- Format detection ---
export interface ResolvedResource {
format: ResourceFormat;
contentPath: string;
}
export async function resolveResource(type: ResourceType, slug: string): Promise<ResolvedResource | null> {
// Folder has priority
const mfp = mainFilePath(type, slug);
try {
await fs.access(mfp);
return { format: 'folder', contentPath: mfp };
} catch { /* not a folder */ }
const fp = filePath(type, slug);
try {
await fs.access(fp);
return { format: 'file', contentPath: fp };
} catch { /* not found */ }
return null;
}
// --- Sub-file helpers ---
async function collectFiles(dirPath: string): Promise<ResourceFileEntry[]> {
const entries: ResourceFileEntry[] = [];
async function walk(current: string, prefix: string) {
let items: import('node:fs').Dirent[];
try {
items = await fs.readdir(current, { withFileTypes: true });
} catch {
return;
}
for (const item of items) {
const rel = prefix ? `${prefix}/${item.name}` : item.name;
if (item.isDirectory()) {
await walk(path.join(current, item.name), rel);
} else {
const stat = await fs.stat(path.join(current, item.name));
entries.push({ relativePath: rel, size: stat.size });
}
}
}
await walk(dirPath, '');
return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}
function validateRelativePath(relativePath: string): void {
if (relativePath.includes('..') || path.isAbsolute(relativePath)) {
throw new Error('Invalid file path: must be relative and cannot contain ..');
}
const parts = relativePath.split('/');
if (parts.length < 2 || !FOLDER_SUBDIRS.includes(parts[0] as typeof FOLDER_SUBDIRS[number])) {
throw new Error(`File path must start with one of: ${FOLDER_SUBDIRS.join(', ')}`);
}
}
// --- CRUD ---
export async function listResources(type: ResourceType): Promise<ResourceMeta[]> {
const config = getTypeConfig(type);
await fs.mkdir(config.dataDir, { recursive: true });
const entries = await fs.readdir(config.dataDir, { withFileTypes: true });
const resources: ResourceMeta[] = [];
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
// Simple file resource
const slug = entry.name.replace(/\.md$/, '');
// Skip if a valid folder with same slug exists (folder takes priority)
const folderMainFile = path.join(config.dataDir, slug, config.mainFileName);
try {
await fs.access(folderMainFile);
continue; // Folder resource exists, skip the .md file
} catch { /* no folder, fine */ }
const raw = await fs.readFile(path.join(config.dataDir, entry.name), 'utf-8');
const { content: _, raw: __, files: ___, ...meta } = parseResource(type, slug, raw, 'file');
resources.push(meta);
} else if (entry.isDirectory()) {
// Folder resource — must have mainFileName
const mfp = path.join(config.dataDir, entry.name, config.mainFileName);
try {
const raw = await fs.readFile(mfp, 'utf-8');
const { content: _, raw: __, files: ___, ...meta } = parseResource(type, entry.name, raw, 'folder');
resources.push(meta);
} catch {
// Directory without mainFileName — skip
}
}
}
return resources.sort((a, b) => a.name.localeCompare(b.name));
}
export async function listAllResources(): Promise<ResourceMeta[]> {
const all: ResourceMeta[] = [];
for (const type of RESOURCE_TYPES) {
const resources = await listResources(type);
all.push(...resources);
}
return all.sort((a, b) => a.name.localeCompare(b.name));
}
export async function getAllTags(type?: ResourceType): Promise<string[]> {
if (type) {
const all = await listResources(type);
return [...new Set(all.flatMap(r => r.tags))].sort();
}
const all = await listAllResources();
return [...new Set(all.flatMap(r => r.tags))].sort();
}
export async function getForksOf(type: ResourceType, slug: string): Promise<ResourceMeta[]> {
const all = await listResources(type);
return all.filter(r => r['fork-of'] === slug);
}
export async function getResource(type: ResourceType, slug: string): Promise<Resource | null> {
const resolved = await resolveResource(type, slug);
if (!resolved) return null;
const raw = await fs.readFile(resolved.contentPath, 'utf-8');
let files: ResourceFileEntry[] = [];
if (resolved.format === 'folder') {
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
const config = getTypeConfig(type);
// Exclude the main file from the files list
files = allFiles.filter(f => f.relativePath !== config.mainFileName);
}
return parseResource(type, slug, raw, resolved.format, files);
}
export async function createResource(
type: ResourceType,
slug: string,
content: string,
format: ResourceFormat = 'file',
): Promise<Resource> {
if (!isValidSlug(slug)) {
throw new Error(`Invalid slug: ${slug}`);
}
const config = getTypeConfig(type);
await fs.mkdir(config.dataDir, { recursive: true });
// Check both formats to prevent duplicates
const existing = await resolveResource(type, slug);
if (existing) {
throw new Error(`${config.labelSingular} already exists: ${slug}`);
}
if (format === 'folder') {
const dir = folderPath(type, slug);
await fs.mkdir(dir, { recursive: true });
const mfp = mainFilePath(type, slug);
await fs.writeFile(mfp, content, 'utf-8');
return parseResource(type, slug, content, 'folder');
}
const dest = filePath(type, slug);
await fs.writeFile(dest, content, 'utf-8');
return parseResource(type, slug, content, 'file');
}
export async function updateResource(type: ResourceType, slug: string, content: string): Promise<Resource> {
const config = getTypeConfig(type);
const resolved = await resolveResource(type, slug);
if (!resolved) {
throw new Error(`${config.labelSingular} not found: ${slug}`);
}
await fs.writeFile(resolved.contentPath, content, 'utf-8');
let files: ResourceFileEntry[] = [];
if (resolved.format === 'folder') {
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
files = allFiles.filter(f => f.relativePath !== config.mainFileName);
}
return parseResource(type, slug, content, resolved.format, files);
}
export async function deleteResource(type: ResourceType, slug: string): Promise<void> {
const config = getTypeConfig(type);
const resolved = await resolveResource(type, slug);
if (!resolved) {
throw new Error(`${config.labelSingular} not found: ${slug}`);
}
if (resolved.format === 'folder') {
await fs.rm(folderPath(type, slug), { recursive: true });
} else {
await fs.unlink(resolved.contentPath);
}
}
// --- Sub-file operations (folder resources only) ---
export async function listResourceFiles(type: ResourceType, slug: string): Promise<ResourceFileEntry[]> {
const config = getTypeConfig(type);
const resolved = await resolveResource(type, slug);
if (!resolved || resolved.format !== 'folder') {
return [];
}
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
return allFiles.filter(f => f.relativePath !== config.mainFileName);
}
export async function getResourceFile(type: ResourceType, slug: string, relativePath: string): Promise<Buffer | null> {
validateRelativePath(relativePath);
const resolved = await resolveResource(type, slug);
if (!resolved || resolved.format !== 'folder') return null;
const target = path.join(folderPath(type, slug), relativePath);
try {
return await fs.readFile(target);
} catch {
return null;
}
}
export async function addResourceFile(type: ResourceType, slug: string, relativePath: string, data: Buffer): Promise<void> {
validateRelativePath(relativePath);
const resolved = await resolveResource(type, slug);
if (!resolved || resolved.format !== 'folder') {
throw new Error('Resource is not a folder');
}
const target = path.join(folderPath(type, slug), relativePath);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, data);
}
export async function deleteResourceFile(type: ResourceType, slug: string, relativePath: string): Promise<void> {
validateRelativePath(relativePath);
const resolved = await resolveResource(type, slug);
if (!resolved || resolved.format !== 'folder') {
throw new Error('Resource is not a folder');
}
const target = path.join(folderPath(type, slug), relativePath);
await fs.unlink(target);
}
export async function convertToFolder(type: ResourceType, slug: string): Promise<Resource> {
const config = getTypeConfig(type);
const fp = filePath(type, slug);
let raw: string;
try {
raw = await fs.readFile(fp, 'utf-8');
} catch {
throw new Error(`${config.labelSingular} not found or already a folder: ${slug}`);
}
const dir = folderPath(type, slug);
await fs.mkdir(dir, { recursive: true });
const mfp = mainFilePath(type, slug);
await fs.writeFile(mfp, raw, 'utf-8');
await fs.unlink(fp);
return parseResource(type, slug, raw, 'folder');
}

View File

@@ -1,19 +1,30 @@
import { listResources, getResource } from './resources';
import { listSkills } from './skills';
import { type ResourceType, REGISTRY, RESOURCE_TYPES, getTypeConfig } from './registry';
export function isPowerShell(request: Request): boolean {
const ua = request.headers.get('user-agent') || '';
return /PowerShell/i.test(ua);
}
// --- Push scripts (type-aware) ---
export async function buildPushScript(baseUrl: string, skillsDir: string): Promise<string> {
return buildPushScriptForType(baseUrl, skillsDir, 'skills');
}
export async function buildPushScriptForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise<string> {
const config = getTypeConfig(type);
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SKILLS_DIR="${skillsDir}"`,
`RESOURCE_DIR="${resourceDir}"`,
`BASE_URL="${baseUrl}"`,
`RESOURCE_TYPE="${type}"`,
`MAIN_FILE_NAME="${config.mainFileName}"`,
'FILTER="${1:-}"',
'TOKEN_FILE="$HOME/.claude/skills.here-token"',
'TOKEN_FILE="$HOME/.claude/grimoired-token"',
'',
'# Get git author if available',
'AUTHOR_NAME=$(git config user.name 2>/dev/null || echo "")',
@@ -40,15 +51,15 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' echo " Token saved to $TOKEN_FILE"',
' elif [ "$REGISTER_STATUS" = "409" ]; then',
' echo " Email already registered. Place your token in $TOKEN_FILE"',
' echo " Continuing without token (unprotected skills only)..."',
' echo " Continuing without token (unprotected resources only)..."',
' else',
' echo " Registration failed ($REGISTER_STATUS): $REGISTER_BODY"',
' echo " Continuing without token (unprotected skills only)..."',
' echo " Continuing without token (unprotected resources only)..."',
' fi',
'fi',
'',
'if [ ! -d "$SKILLS_DIR" ]; then',
' echo "No skills directory found at $SKILLS_DIR"',
'if [ ! -d "$RESOURCE_DIR" ]; then',
' echo "No directory found at $RESOURCE_DIR"',
' exit 1',
'fi',
'',
@@ -57,7 +68,7 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' AUTH_HEADER="Authorization: Bearer $TOKEN"',
'fi',
'',
'push_skill() {',
'push_file_resource() {',
' local file="$1"',
' local slug=$(basename "$file" .md)',
' local content=$(cat "$file")',
@@ -67,12 +78,6 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" \'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}\')',
' fi',
'',
' # Build curl auth args',
' local auth_args=""',
' if [ -n "$AUTH_HEADER" ]; then',
' auth_args="-H \\"$AUTH_HEADER\\""',
' fi',
'',
' # Try PUT (update), fallback to POST (create)',
' local response',
' if [ -n "$TOKEN" ]; then',
@@ -80,12 +85,12 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
' else',
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
' fi',
'',
' if [ "$response" = "403" ]; then',
@@ -99,12 +104,12 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
' "$BASE_URL/api/resources/$RESOURCE_TYPE")',
' else',
' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
' "$BASE_URL/api/resources/$RESOURCE_TYPE")',
' fi',
' if [ "$post_status" = "403" ]; then',
' echo " ✗ $slug (permission denied — token missing or invalid)"',
@@ -115,25 +120,120 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' echo " ✓ $slug"',
'}',
'',
'push_folder_resource() {',
' local dir="$1"',
' local slug=$(basename "$dir")',
' local main_file="$dir/$MAIN_FILE_NAME"',
'',
' if [ ! -f "$main_file" ]; then',
' echo " ✗ $slug (no $MAIN_FILE_NAME found)"',
' return 1',
' fi',
'',
' local content=$(cat "$main_file")',
'',
' # Inject author',
' if [ -n "$AUTHOR_EMAIL" ] && ! echo "$content" | grep -q "^author-email:"; then',
' content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" \'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}\')',
' fi',
'',
' # Create as folder format, try PUT first then POST',
' local response',
' if [ -n "$TOKEN" ]; then',
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
' else',
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
' fi',
'',
' if [ "$response" = "403" ]; then',
' echo " ✗ $slug (permission denied)"',
' return 1',
' fi',
'',
' if [ "$response" = "404" ]; then',
' if [ -n "$TOKEN" ]; then',
' curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .), \\"format\\": \\"folder\\"}" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE" > /dev/null',
' else',
' curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .), \\"format\\": \\"folder\\"}" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE" > /dev/null',
' fi',
' fi',
'',
' # Upload sub-files',
' local file_count=0',
' for subdir in scripts references assets; do',
' if [ -d "$dir/$subdir" ]; then',
' find "$dir/$subdir" -type f | while read -r subfile; do',
' local rel_path="${subfile#$dir/}"',
' local auth_flag=""',
' if [ -n "$TOKEN" ]; then',
' auth_flag="-H \\"Authorization: Bearer $TOKEN\\""',
' fi',
' if [ -n "$TOKEN" ]; then',
' curl -sS -X PUT \\',
' -H "Authorization: Bearer $TOKEN" \\',
' --data-binary "@$subfile" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug/files/$rel_path" > /dev/null',
' else',
' curl -sS -X PUT \\',
' --data-binary "@$subfile" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug/files/$rel_path" > /dev/null',
' fi',
' done',
' fi',
' done',
'',
' echo " ✓ $slug (folder)"',
'}',
'',
'count=0',
'failed=0',
'if [ -n "$FILTER" ]; then',
' # Push a specific skill',
' file="$SKILLS_DIR/${FILTER%.md}.md"',
' if [ ! -f "$file" ]; then',
' echo "Skill not found: $file"',
' # Check if filter matches a directory (folder resource)',
' dir_path="$RESOURCE_DIR/${FILTER%.md}"',
' dir_path="${dir_path%.md}"',
' file_path="$RESOURCE_DIR/${FILTER%.md}.md"',
' if [ -d "$dir_path" ] && [ -f "$dir_path/$MAIN_FILE_NAME" ]; then',
' if push_folder_resource "$dir_path"; then count=1; else failed=1; fi',
' elif [ -f "$file_path" ]; then',
' if push_file_resource "$file_path"; then count=1; else failed=1; fi',
' else',
' echo "Resource not found: $FILTER"',
' exit 1',
' fi',
' if push_skill "$file"; then',
' count=1',
' else',
' failed=1',
' fi',
'else',
' # Push all skills',
' for file in "$SKILLS_DIR"/*.md; do',
' # Push all .md files (simple format)',
' for file in "$RESOURCE_DIR"/*.md; do',
' [ -f "$file" ] || continue',
' if push_skill "$file"; then',
' slug_name=$(basename "$file" .md)',
' # Skip if folder version exists',
' if [ -d "$RESOURCE_DIR/$slug_name" ] && [ -f "$RESOURCE_DIR/$slug_name/$MAIN_FILE_NAME" ]; then',
' continue',
' fi',
' if push_file_resource "$file"; then',
' count=$((count + 1))',
' else',
' failed=$((failed + 1))',
' fi',
' done',
' # Push all directories (folder format)',
' for dir in "$RESOURCE_DIR"/*/; do',
' [ -d "$dir" ] || continue',
' [ -f "$dir/$MAIN_FILE_NAME" ] || continue',
' if push_folder_resource "$dir"; then',
' count=$((count + 1))',
' else',
' failed=$((failed + 1))',
@@ -141,9 +241,9 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' done',
'fi',
'',
'echo "Pushed $count skill(s) to $BASE_URL"',
`echo "Pushed $count resource(s) to $BASE_URL"`,
'if [ "$failed" -gt 0 ]; then',
' echo "$failed skill(s) failed (permission denied)"',
' echo "$failed resource(s) failed (permission denied)"',
'fi',
'',
];
@@ -151,63 +251,120 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
return lines.join('\n');
}
// --- Sync scripts (type-aware) ---
export async function buildSyncScript(baseUrl: string, skillsDir: string): Promise<string> {
const skills = await listSkills();
return buildSyncScriptForType(baseUrl, skillsDir, 'skills');
}
export async function buildSyncScriptForType(baseUrl: string, targetDir: string, type: ResourceType): Promise<string> {
const resources = await listResources(type);
const config = getTypeConfig(type);
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SKILLS_DIR="${skillsDir}"`,
'mkdir -p "$SKILLS_DIR"',
`TARGET_DIR="${targetDir}"`,
'mkdir -p "$TARGET_DIR"',
'',
];
if (skills.length === 0) {
lines.push('echo "No skills available to sync."');
if (resources.length === 0) {
lines.push(`echo "No ${type} available to sync."`);
} else {
lines.push(`echo "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push(`echo "Syncing ${resources.length} ${type} from ${baseUrl}..."`);
lines.push('');
for (const skill of skills) {
const skillUrl = `${baseUrl}/${skill.slug}`;
lines.push(`curl -fsSL "${skillUrl}" -o "$SKILLS_DIR/${skill.slug}.md"`);
lines.push(`echo " ✓ ${skill.name}"`);
for (const r of resources) {
if (r.format === 'folder') {
// Fetch full resource to get file list
const full = await getResource(type, r.slug);
if (!full) continue;
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}"`);
lines.push(`curl -fsSL "${baseUrl}/${type}/${r.slug}" -o "$TARGET_DIR/${r.slug}/${config.mainFileName}"`);
for (const f of full.files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}/${dir}"`);
}
lines.push(`curl -fsSL "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -o "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
}
const scriptFiles = full.files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
}
lines.push(`echo " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
} else {
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
lines.push(`curl -fsSL "${resourceUrl}" -o "$TARGET_DIR/${r.slug}.md"`);
lines.push(`echo " ✓ ${r.name}"`);
}
}
lines.push('');
lines.push('echo "Done! Skills synced to $SKILLS_DIR"');
lines.push('echo "Done! Synced to $TARGET_DIR"');
}
lines.push('');
return lines.join('\n');
}
// --- PowerShell variants ---
export async function buildSyncScriptPS(baseUrl: string, skillsDir: string): Promise<string> {
const skills = await listSkills();
return buildSyncScriptPSForType(baseUrl, skillsDir, 'skills');
}
export async function buildSyncScriptPSForType(baseUrl: string, targetDir: string, type: ResourceType): Promise<string> {
const resources = await listResources(type);
const config = getTypeConfig(type);
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$SkillsDir = "${skillsDir}"`,
'New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null',
`$TargetDir = "${targetDir}"`,
'New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null',
'',
];
if (skills.length === 0) {
lines.push('Write-Host "No skills available to sync."');
if (resources.length === 0) {
lines.push(`Write-Host "No ${type} available to sync."`);
} else {
lines.push(`Write-Host "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push(`Write-Host "Syncing ${resources.length} ${type} from ${baseUrl}..."`);
lines.push('');
for (const skill of skills) {
const skillUrl = `${baseUrl}/${skill.slug}`;
lines.push(`Invoke-WebRequest -Uri "${skillUrl}" -OutFile (Join-Path $SkillsDir "${skill.slug}.md")`);
lines.push(`Write-Host " ✓ ${skill.name}"`);
for (const r of resources) {
if (r.format === 'folder') {
const full = await getResource(type, r.slug);
if (!full) continue;
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${r.slug}") | Out-Null`);
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/${type}/${r.slug}" -OutFile (Join-Path $TargetDir "${r.slug}\\${config.mainFileName}")`);
for (const f of full.files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${r.slug}\\${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -OutFile (Join-Path $TargetDir "${r.slug}\\${winPath}")`);
}
lines.push(`Write-Host " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
} else {
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
lines.push(`Invoke-WebRequest -Uri "${resourceUrl}" -OutFile (Join-Path $TargetDir "${r.slug}.md")`);
lines.push(`Write-Host " ✓ ${r.name}"`);
}
}
lines.push('');
lines.push('Write-Host "Done! Skills synced to $SkillsDir"');
lines.push('Write-Host "Done! Synced to $TargetDir"');
}
lines.push('');
@@ -215,13 +372,20 @@ export async function buildSyncScriptPS(baseUrl: string, skillsDir: string): Pro
}
export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Promise<string> {
return buildPushScriptPSForType(baseUrl, skillsDir, 'skills');
}
export async function buildPushScriptPSForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise<string> {
const config = getTypeConfig(type);
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$SkillsDir = "${skillsDir}"`,
`$ResourceDir = "${resourceDir}"`,
`$BaseUrl = "${baseUrl}"`,
`$ResourceType = "${type}"`,
`$MainFileName = "${config.mainFileName}"`,
'$Filter = if ($args.Count -gt 0) { $args[0] } else { "" }',
'$TokenFile = Join-Path $HOME ".claude\\skills.here-token"',
'$TokenFile = Join-Path $HOME ".claude\\grimoired-token"',
'',
'# Get git author if available',
'$AuthorName = try { git config user.name 2>$null } catch { "" }',
@@ -249,19 +413,19 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
' } else {',
' Write-Host " Registration failed: $_"',
' }',
' Write-Host " Continuing without token (unprotected skills only)..."',
' Write-Host " Continuing without token (unprotected resources only)..."',
' }',
'}',
'',
'if (-not (Test-Path $SkillsDir)) {',
' Write-Host "No skills directory found at $SkillsDir"',
'if (-not (Test-Path $ResourceDir)) {',
' Write-Host "No directory found at $ResourceDir"',
' exit 1',
'}',
'',
'$headers = @{ "Content-Type" = "application/json" }',
'if ($Token) { $headers["Authorization"] = "Bearer $Token" }',
'',
'function Push-Skill($file) {',
'function Push-FileResource($file) {',
' $slug = [IO.Path]::GetFileNameWithoutExtension($file)',
' $content = Get-Content $file -Raw',
'',
@@ -275,7 +439,7 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
'',
' $body = @{ content = $content } | ConvertTo-Json',
' try {',
' Invoke-WebRequest -Uri "$BaseUrl/api/skills/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' Write-Host " ✓ $slug"',
' return $true',
' } catch {',
@@ -287,7 +451,7 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
' if ($code -eq 404) {',
' $postBody = @{ slug = $slug; content = $content } | ConvertTo-Json',
' try {',
' Invoke-WebRequest -Uri "$BaseUrl/api/skills" -Method POST -Headers $headers -Body $postBody | Out-Null',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType" -Method POST -Headers $headers -Body $postBody | Out-Null',
' Write-Host " ✓ $slug"',
' return $true',
' } catch {',
@@ -303,19 +467,92 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
' return $true',
'}',
'',
'function Push-FolderResource($dir) {',
' $slug = Split-Path $dir -Leaf',
' $mainFile = Join-Path $dir $MainFileName',
' if (-not (Test-Path $mainFile)) {',
' Write-Host " ✗ $slug (no $MainFileName found)"',
' return $false',
' }',
' $content = Get-Content $mainFile -Raw',
'',
' if ($AuthorEmail -and $content -notmatch "(?m)^author-email:") {',
' $inject = ""',
' if ($AuthorName) { $inject += "author: $AuthorName`n" }',
' $inject += "author-email: $AuthorEmail"',
' $content = $content -replace "^---$", "---`n$inject" -replace "^---`n", "---`n"',
' }',
'',
' $body = @{ content = $content } | ConvertTo-Json',
' try {',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' } catch {',
' $code = $_.Exception.Response.StatusCode.value__',
' if ($code -eq 403) {',
' Write-Host " ✗ $slug (permission denied)"',
' return $false',
' }',
' if ($code -eq 404) {',
' $postBody = @{ slug = $slug; content = $content; format = "folder" } | ConvertTo-Json',
' try {',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType" -Method POST -Headers $headers -Body $postBody | Out-Null',
' } catch {',
' $postCode = $_.Exception.Response.StatusCode.value__',
' if ($postCode -eq 403) {',
' Write-Host " ✗ $slug (permission denied)"',
' return $false',
' }',
' }',
' }',
' }',
'',
' # Upload sub-files',
' foreach ($subdir in @("scripts", "references", "assets")) {',
' $subdirPath = Join-Path $dir $subdir',
' if (Test-Path $subdirPath) {',
' Get-ChildItem -Path $subdirPath -Recurse -File | ForEach-Object {',
' $relPath = $_.FullName.Substring($dir.Length + 1).Replace("\\", "/")',
' $fileHeaders = @{}',
' if ($Token) { $fileHeaders["Authorization"] = "Bearer $Token" }',
' $fileBytes = [IO.File]::ReadAllBytes($_.FullName)',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug/files/$relPath" -Method PUT -Headers $fileHeaders -Body $fileBytes | Out-Null',
' }',
' }',
' }',
'',
' Write-Host " ✓ $slug (folder)"',
' return $true',
'}',
'',
'$count = 0; $failed = 0',
'if ($Filter) {',
' $file = Join-Path $SkillsDir "$($Filter -replace \'\\.md$\',\'\').md"',
' if (-not (Test-Path $file)) { Write-Host "Skill not found: $file"; exit 1 }',
' if (Push-Skill $file) { $count++ } else { $failed++ }',
' $dirPath = Join-Path $ResourceDir ($Filter -replace "\\.md$","")',
' $filePath = Join-Path $ResourceDir "$($Filter -replace \'\\.md$\',\'\').md"',
' if ((Test-Path $dirPath -PathType Container) -and (Test-Path (Join-Path $dirPath $MainFileName))) {',
' if (Push-FolderResource $dirPath) { $count++ } else { $failed++ }',
' } elseif (Test-Path $filePath) {',
' if (Push-FileResource $filePath) { $count++ } else { $failed++ }',
' } else {',
' Write-Host "Resource not found: $Filter"; exit 1',
' }',
'} else {',
' Get-ChildItem -Path $SkillsDir -Filter "*.md" | ForEach-Object {',
' if (Push-Skill $_.FullName) { $count++ } else { $failed++ }',
' # Push .md files (skip if folder version exists)',
' Get-ChildItem -Path $ResourceDir -Filter "*.md" | ForEach-Object {',
' $slugName = $_.BaseName',
' $folderPath = Join-Path $ResourceDir $slugName',
' if ((Test-Path $folderPath -PathType Container) -and (Test-Path (Join-Path $folderPath $MainFileName))) { return }',
' if (Push-FileResource $_.FullName) { $count++ } else { $failed++ }',
' }',
' # Push directories',
' Get-ChildItem -Path $ResourceDir -Directory | ForEach-Object {',
' if (Test-Path (Join-Path $_.FullName $MainFileName)) {',
' if (Push-FolderResource $_.FullName) { $count++ } else { $failed++ }',
' }',
' }',
'}',
'',
'Write-Host "Pushed $count skill(s) to $BaseUrl"',
'if ($failed -gt 0) { Write-Host "$failed skill(s) failed (permission denied)" }',
'Write-Host "Pushed $count resource(s) to $BaseUrl"',
'if ($failed -gt 0) { Write-Host "$failed resource(s) failed (permission denied)" }',
'',
];

View File

@@ -0,0 +1,344 @@
---
import Base from '../../layouts/Base.astro';
import EditGate from '../../components/EditGate.vue';
import DeleteButton from '../../components/DeleteButton.vue';
import FolderTree from '../../components/FolderTree.astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../lib/registry';
import { getResource, getForksOf } from '../../lib/resources';
import { hasToken } from '../../lib/tokens';
import { recordDownload, getStatsForSlug } from '../../lib/stats';
import { marked } from 'marked';
const { type, slug } = Astro.params;
if (!type || !isValidResourceType(type)) {
return new Response(null, { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return Astro.redirect('/');
}
// curl / wget → raw markdown
const accept = Astro.request.headers.get('accept') || '';
if (!accept.includes('text/html')) {
recordDownload(slug!, resourceType);
return new Response(resource.raw, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
}
const authorHasToken = resource['author-email'] ? await hasToken(resource['author-email']) : false;
const forks = await getForksOf(resourceType, slug!);
const stats = await getStatsForSlug(slug!, resourceType);
const html = await marked(resource.content);
const origin = Astro.url.origin;
const cmds = {
unix: `curl -fsSL ${origin}/${type}/${slug}/i | bash`,
unixGlobal: `curl -fsSL ${origin}/${type}/${slug}/gi | bash`,
win: `irm ${origin}/${type}/${slug}/i | iex`,
winGlobal: `irm ${origin}/${type}/${slug}/gi | iex`,
};
// Extract display fields from frontmatter
const fields = resource.fields;
const allowedTools = Array.isArray(fields['allowed-tools'] ?? fields.allowedTools)
? (fields['allowed-tools'] ?? fields.allowedTools) as string[]
: typeof (fields['allowed-tools'] ?? fields.allowedTools) === 'string'
? (fields['allowed-tools'] ?? fields.allowedTools as string).split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
---
<Base title={`${resource.name} — Grimoired`}>
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Back to {config.label}
</a>
<!-- Header + Install -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;">
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6" style="min-width: 0;">
<div class="flex items-center gap-2 mb-1">
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-semibold" style={`background: ${config.color}20; color: ${config.color}; border: 1px solid ${config.color}40;`}>
{config.labelSingular}
</span>
</div>
<h1 class="text-2xl font-bold tracking-tight text-white mb-1">{resource.name}</h1>
{resource.description && <p class="text-gray-500 leading-relaxed mb-3">{resource.description}</p>}
{allowedTools.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{allowedTools.map((tool: string) => (
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400">
{tool}
</span>
))}
</div>
)}
{resource.tags.length > 0 && (
<div class="flex flex-wrap gap-1.5 mt-3">
{resource.tags.map((tag) => (
<span class="rounded-full bg-[var(--color-accent-500)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]">
{tag}
</span>
))}
</div>
)}
{resource.author && (
<p class="text-xs text-gray-600 mt-3">by {resource.author}</p>
)}
{resource['fork-of'] && (
<p class="text-xs text-gray-600 mt-1">forked from <a href={`/${type}/${resource['fork-of']}`} class="text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">{resource['fork-of']}</a></p>
)}
{forks.length > 0 && (
<details class="mt-3">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300 transition-colors select-none">
{forks.length} fork{forks.length !== 1 ? 's' : ''}
</summary>
<ul class="mt-1.5 space-y-1 pl-3 border-l border-white/[0.06]">
{forks.map((f) => (
<li>
<a href={`/${type}/${f.slug}`} class="text-xs text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">
{f.name}
{f.author && <span class="text-gray-600"> by {f.author}</span>}
</a>
</li>
))}
</ul>
</details>
)}
<div class="flex flex-wrap items-center gap-4 mt-4 pt-3 border-t border-white/[0.06]">
<span class="inline-flex items-center gap-1.5 text-xs text-gray-500">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{stats.downloads} download{stats.downloads !== 1 ? 's' : ''}
</span>
<span class="inline-flex items-center gap-1.5 text-xs text-gray-500">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
{stats.pushes} push{stats.pushes !== 1 ? 'es' : ''}
</span>
{stats.lastPushedAt && (
<span class="text-xs text-gray-600">
Last updated {new Date(stats.lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
)}
</div>
</div>
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 space-y-4" style="min-width: 0;">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white">Install this {config.labelSingular.toLowerCase()}</h2>
<div class="flex rounded-lg border border-white/[0.06] overflow-hidden" id="os-tabs">
<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>
</div>
<p class="text-xs text-gray-500 leading-relaxed">Run in your project root to add this {config.labelSingular.toLowerCase()}.</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-xs font-mono text-gray-500 select-all truncate">{cmds.unix}</code>
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-500 select-all truncate hidden">{cmds.win}</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">
<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 data-cmd="unix" class="flex-1 font-mono text-gray-500 select-all truncate">{cmds.unixGlobal}</code>
<code data-cmd="win" class="flex-1 font-mono text-gray-500 select-all truncate hidden">{cmds.winGlobal}</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>
</div>
</div>
</div>
</details>
</div>
</div>
<!-- Content + Files -->
{resource.format === 'folder' ? (
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start;">
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-5 sticky top-4">
<h2 class="text-sm font-semibold text-white mb-3">Files</h2>
<FolderTree
files={resource.files}
slug={slug!}
type={type}
mainFileName={config.mainFileName}
mainFileSize={new TextEncoder().encode(resource.raw).length}
/>
</div>
<div id="content-panel" class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8" style="min-width: 0;">
<div class="absolute top-4 right-4 flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-[10px] font-medium bg-white/[0.06] text-gray-500 border border-white/[0.06]">folder</span>
<EditGate slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
<DeleteButton slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
</div>
<article id="content-body" class="skill-prose" set:html={html} />
</div>
</div>
) : (
<div class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8">
<div class="absolute top-4 right-4 flex items-center gap-2">
<EditGate slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
<DeleteButton slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
</div>
<article class="skill-prose" set:html={html} />
</div>
)}
</Base>
<style>
.os-tab { color: var(--color-gray-600); }
.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);
}
});
});
// File viewer for folder resources
const contentBody = document.getElementById('content-body');
if (contentBody) {
const mainHtml = contentBody.innerHTML;
const treeItems = document.querySelectorAll<HTMLButtonElement>('.tree-item');
function setActive(btn: HTMLButtonElement) {
treeItems.forEach(t => t.classList.remove('active'));
btn.classList.add('active');
}
function isTextResponse(contentType: string): boolean {
if (contentType.startsWith('text/')) return true;
const textTypes = ['application/json', 'application/javascript', 'application/xml', 'application/yaml', 'application/toml', 'application/x-sh'];
return textTypes.some(t => contentType.startsWith(t));
}
function isMdFile(path: string): boolean {
return path.split('.').pop()?.toLowerCase() === 'md';
}
// Main file button
document.querySelector<HTMLButtonElement>('[data-file-main]')?.addEventListener('click', (e) => {
setActive(e.currentTarget as HTMLButtonElement);
contentBody.innerHTML = mainHtml;
contentBody.className = 'skill-prose';
});
// Sub-file buttons
document.querySelectorAll<HTMLButtonElement>('[data-file-path]').forEach(btn => {
btn.addEventListener('click', async () => {
const filePath = btn.dataset.filePath!;
setActive(btn);
const fileName = filePath.split('/').pop() || filePath;
contentBody.className = '';
contentBody.innerHTML = `<p class="text-sm text-gray-500">Loading ${fileName}...</p>`;
try {
const [, rType, rSlug] = window.location.pathname.split('/');
const apiUrl = `/api/resources/${rType}/${rSlug}/files/${filePath}`;
const res = await fetch(apiUrl);
if (!res.ok) throw new Error(`${res.status}`);
const ct = res.headers.get('content-type') || '';
const treatAsText = isTextResponse(ct) || ct === 'application/octet-stream';
if (treatAsText) {
const text = await res.text();
// If octet-stream, verify it looks like text (no null bytes)
if (ct === 'application/octet-stream' && /\0/.test(text)) {
// Actually binary — show download
const blob = new Blob([text]);
const blobUrl = URL.createObjectURL(blob);
contentBody.innerHTML = `
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg class="h-12 w-12 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<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.25m2.25 0H5.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>
<p class="text-sm text-gray-400 mb-1">${fileName}</p>
<p class="text-xs text-gray-600 mb-4">${filePath}</p>
<a href="${blobUrl}" download="${fileName}" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-accent-400)] transition-colors">Download file</a>
</div>
`;
} else {
const header = document.createElement('div');
header.className = 'flex items-center gap-2 mb-4 pb-3 border-b border-white/[0.06]';
header.innerHTML = `
<span class="text-xs font-mono text-gray-500">${filePath}</span>
<a href="${apiUrl}" download class="ml-auto text-xs text-gray-600 hover:text-gray-400 transition-colors">Download</a>
`;
if (isMdFile(filePath)) {
const { marked: clientMarked } = await import('marked');
const rendered = await clientMarked(text);
contentBody.innerHTML = '';
contentBody.appendChild(header);
const article = document.createElement('article');
article.className = 'skill-prose';
article.innerHTML = rendered;
contentBody.appendChild(article);
} else {
contentBody.innerHTML = '';
contentBody.appendChild(header);
const pre = document.createElement('pre');
pre.className = 'rounded-xl bg-surface-50 border border-white/[0.06] p-4 overflow-x-auto';
const code = document.createElement('code');
code.className = 'text-sm font-mono text-gray-300 whitespace-pre';
code.textContent = text;
pre.appendChild(code);
contentBody.appendChild(pre);
}
}
} else {
// Binary file — show download link
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
contentBody.innerHTML = `
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg class="h-12 w-12 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<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.25m2.25 0H5.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>
<p class="text-sm text-gray-400 mb-1">${fileName}</p>
<p class="text-xs text-gray-600 mb-4">${filePath}</p>
<a href="${blobUrl}" download="${fileName}" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-accent-400)] transition-colors">Download file</a>
</div>
`;
}
} catch (err) {
contentBody.innerHTML = `<p class="text-sm text-red-400">Failed to load file.</p>`;
}
});
});
}
</script>

View File

@@ -0,0 +1,77 @@
---
import Base from '../../../layouts/Base.astro';
import ResourceEditor from '../../../components/ResourceEditor.vue';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource, getAllTags, listResources } from '../../../lib/resources';
import { getAvailableTools } from '../../../lib/tools';
import { getAvailableModels } from '../../../lib/models';
const { type, slug } = Astro.params;
if (!type || !isValidResourceType(type)) {
return new Response(null, { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return Astro.redirect('/');
}
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
const availableTags = await getAllTags(resourceType);
const skillSlugs = (await listResources('skills')).map(r => r.slug);
// Build initial field values
const initialFieldValues: Record<string, unknown> = {};
for (const field of config.fields) {
const val = resource.fields[field.key];
if (val !== undefined && val !== null) {
if (field.type === 'toggle-grid' || field.type === 'tags') {
if (Array.isArray(val)) {
initialFieldValues[field.key] = val;
} else if (typeof val === 'string') {
initialFieldValues[field.key] = val.split(',').map((t: string) => t.trim()).filter(Boolean);
}
} else if (field.type === 'json') {
initialFieldValues[field.key] = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
} else {
initialFieldValues[field.key] = val;
}
}
}
---
<Base title={`Edit ${resource.name} — Grimoired`}>
<a href={`/${type}/${slug}`} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Back to {resource.name}
</a>
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">Edit {config.labelSingular}</h1>
<p class="text-sm text-gray-500 mb-8">Editing <strong class="text-gray-400">{resource.name}</strong>. Users who already installed this will get the updated version on their next sync.</p>
<ResourceEditor
resourceType={type}
typeSingular={config.labelSingular}
typeFields={config.fields}
mode="edit"
slug={slug}
initialName={resource.name}
initialDescription={resource.description}
initialTags={resource.tags.join(', ')}
initialBody={resource.content}
initialAuthor={resource.author}
initialAuthorEmail={resource['author-email']}
initialFormat={resource.format}
initialFieldValues={JSON.stringify(initialFieldValues)}
availableTools={availableTools}
availableModels={availableModels}
availableSkills={skillSlugs}
availableTags={availableTags.join(',')}
client:load
/>
</Base>

View File

@@ -0,0 +1,181 @@
import type { APIRoute } from 'astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
function parseList(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 [];
}
export const GET: APIRoute = async ({ params, url, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response('Not found', { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const claudeDir = config.claudeDir;
// Check for preloaded skills (agents can reference skills)
const preloadedSlugs = parseList(resource.fields.skills);
const depSkills: Array<{ slug: string; name: string }> = [];
for (const skillSlug of preloadedSlugs) {
const skill = await getResource('skills', skillSlug);
if (skill) {
depSkills.push({ slug: skill.slug, name: skill.name });
}
}
const skillsClaudeDir = getTypeConfig('skills').claudeDir;
const files = resource.files;
const script = ps
? buildPS(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir)
: buildBash(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
function buildBash(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`DEST=~/.claude/${claudeDir}/${slug}`,
'mkdir -p "$DEST"',
`curl -fsSL "${origin}/${type}/${slug}" -o "$DEST/${getMainFileName(type)}"`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$DEST/${dir}"`);
}
lines.push(`curl -fsSL "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
}
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
}
lines.push(`echo "✓ Installed ${name} globally to ~/.claude/${claudeDir}/${slug}/ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`mkdir -p ~/.claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ~/.claude/${skillsClaudeDir}/${s.slug}.md`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`mkdir -p ~/.claude/${claudeDir}`,
`curl -fsSL "${origin}/${type}/${slug}" -o ~/.claude/${claudeDir}/${slug}.md`,
`echo "✓ Installed ${name} globally to ~/.claude/${claudeDir}/${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`mkdir -p ~/.claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ~/.claude/${skillsClaudeDir}/${s.slug}.md`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function buildPS(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dest = Join-Path $HOME ".claude\\${claudeDir}\\${slug}"`,
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dest "${getMainFileName(type)}")`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
}
lines.push(`Write-Host "✓ Installed ${name} globally to $Dest\\ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`$SkillsDir = Join-Path $HOME ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dir = Join-Path $HOME ".claude\\${claudeDir}"`,
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${name} globally to $Dir\\${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`$SkillsDir = Join-Path $HOME ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function getMainFileName(type: string): string {
const map: Record<string, string> = {
skills: 'SKILL.md',
agents: 'AGENT.md',
'output-styles': 'OUTPUT-STYLE.md',
rules: 'RULE.md',
};
return map[type] || `${type.toUpperCase()}.md`;
}

View File

@@ -0,0 +1,182 @@
import type { APIRoute } from 'astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
function parseList(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 [];
}
export const GET: APIRoute = async ({ params, url, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response('Not found', { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const claudeDir = config.claudeDir;
// Check for preloaded skills (agents can reference skills)
const preloadedSlugs = parseList(resource.fields.skills);
const depSkills: Array<{ slug: string; name: string }> = [];
for (const skillSlug of preloadedSlugs) {
const skill = await getResource('skills', skillSlug);
if (skill) {
depSkills.push({ slug: skill.slug, name: skill.name });
}
}
const skillsClaudeDir = getTypeConfig('skills').claudeDir;
const files = resource.files;
const script = ps
? buildPS(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir)
: buildBash(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
function buildBash(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`DEST=".claude/${claudeDir}/${slug}"`,
'mkdir -p "$DEST"',
`curl -fsSL "${origin}/${type}/${slug}" -o "$DEST/${getMainFileName(type)}"`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$DEST/${dir}"`);
}
lines.push(`curl -fsSL "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
}
// chmod +x for scripts
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
}
lines.push(`echo "✓ Installed ${name} to .claude/${claudeDir}/${slug}/ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`mkdir -p .claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ".claude/${skillsClaudeDir}/${s.slug}.md"`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`mkdir -p .claude/${claudeDir}`,
`curl -fsSL "${origin}/${type}/${slug}" -o ".claude/${claudeDir}/${slug}.md"`,
`echo "✓ Installed ${name} to .claude/${claudeDir}/${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`mkdir -p .claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ".claude/${skillsClaudeDir}/${s.slug}.md"`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function buildPS(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dest = ".claude\\${claudeDir}\\${slug}"`,
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dest "${getMainFileName(type)}")`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
}
lines.push(`Write-Host "✓ Installed ${name} to .claude\\${claudeDir}\\${slug}\\ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`$SkillsDir = ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dir = ".claude\\${claudeDir}"`,
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${name} to $Dir\\${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`$SkillsDir = ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function getMainFileName(type: string): string {
const map: Record<string, string> = {
skills: 'SKILL.md',
agents: 'AGENT.md',
'output-styles': 'OUTPUT-STYLE.md',
rules: 'RULE.md',
};
return map[type] || `${type.toUpperCase()}.md`;
}

View File

@@ -0,0 +1,85 @@
---
import Base from '../../layouts/Base.astro';
import ResourceEditor from '../../components/ResourceEditor.vue';
import { isValidResourceType, getTypeConfig, REGISTRY, type ResourceType } from '../../lib/registry';
import { getResource, getAllTags, listResources } from '../../lib/resources';
import { getAvailableTools } from '../../lib/tools';
import { getAvailableModels } from '../../lib/models';
const { type } = Astro.params;
if (!type || !isValidResourceType(type)) {
return new Response(null, { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
const availableTags = await getAllTags(resourceType);
const skillSlugs = (await listResources('skills')).map(r => r.slug);
// Fork support: /skills/new?from=original-slug
const fromSlug = Astro.url.searchParams.get('from');
let forkSource: Awaited<ReturnType<typeof getResource>> = null;
if (fromSlug) {
forkSource = await getResource(resourceType, fromSlug);
}
const isFork = Boolean(forkSource);
const title = isFork ? `Fork ${forkSource!.name} — Grimoired` : `New ${config.labelSingular} — Grimoired`;
// Build initial field values from fork source
const initialFieldValues: Record<string, unknown> = {};
if (forkSource) {
for (const field of config.fields) {
const val = forkSource.fields[field.key];
if (val !== undefined && val !== null) {
if (field.type === 'toggle-grid' || field.type === 'tags') {
// Normalize to arrays
if (Array.isArray(val)) {
initialFieldValues[field.key] = val;
} else if (typeof val === 'string') {
initialFieldValues[field.key] = val.split(',').map((t: string) => t.trim()).filter(Boolean);
}
} else if (field.type === 'json') {
initialFieldValues[field.key] = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
} else {
initialFieldValues[field.key] = val;
}
}
}
}
---
<Base title={title}>
<a href={isFork ? `/${type}/${fromSlug}` : '/'} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
{isFork ? `Back to ${forkSource!.name}` : 'Back'}
</a>
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">{isFork ? `Fork ${config.labelSingular}` : `New ${config.labelSingular}`}</h1>
{isFork ? (
<p class="text-sm text-gray-500 mb-8">Creating an independent copy of <strong class="text-gray-400">{forkSource!.name}</strong>. Change the <strong class="text-gray-400">name</strong> to generate a new slug before saving.</p>
) : (
<p class="text-sm text-gray-500 mb-8 max-w-xl">Create a new {config.labelSingular.toLowerCase()}. Write instructions in Markdown in the <strong class="text-gray-400">body</strong> section.</p>
)}
<ResourceEditor
resourceType={type}
typeSingular={config.labelSingular}
typeFields={config.fields}
mode="create"
forkOf={isFork ? fromSlug! : undefined}
initialName={forkSource?.name || ''}
initialDescription={forkSource?.description || ''}
initialTags={forkSource?.tags.join(', ') || ''}
initialBody={forkSource?.content || ''}
initialFieldValues={JSON.stringify(initialFieldValues)}
availableTools={availableTools}
availableModels={availableModels}
availableSkills={skillSlugs}
availableTags={availableTags.join(',')}
client:load
/>
</Base>

View File

@@ -0,0 +1,129 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../../lib/registry';
import { getResource, updateResource, deleteResource } from '../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../lib/tokens';
import { recordPush } from '../../../../lib/stats';
export const GET: APIRoute = async ({ params, request }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response('Not found', { status: 404 });
}
const resource = await getResource(type, params.slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
// If JSON requested, include format and files metadata
const accept = request.headers.get('accept') || '';
if (accept.includes('application/json')) {
return new Response(JSON.stringify(resource), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(resource.raw, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
};
export const PUT: APIRoute = async ({ params, request }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
let body: { content?: string };
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (!body.content) {
return new Response(JSON.stringify({ error: 'content is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const existing = await getResource(type, params.slug!);
if (!existing) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
if (existing['author-email'] && await hasToken(existing['author-email'])) {
const token = extractBearerToken(request);
const valid = await verifyToken(existing['author-email'], token);
if (!valid) {
return new Response(JSON.stringify({ error: `Only ${existing.author || existing['author-email']} can update this resource.` }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
const resource = await updateResource(type, params.slug!, body.content);
recordPush(params.slug!, type);
return new Response(JSON.stringify(resource), {
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};
export const DELETE: APIRoute = async ({ params, request }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const existing = await getResource(type, params.slug!);
if (!existing) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
if (existing['author-email'] && await hasToken(existing['author-email'])) {
const token = extractBearerToken(request);
const valid = await verifyToken(existing['author-email'], token);
if (!valid) {
return new Response(JSON.stringify({ error: `Only ${existing.author || existing['author-email']} can delete this resource.` }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
await deleteResource(type, params.slug!);
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -0,0 +1,113 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../../../../lib/registry';
import { getResource, getResourceFile, addResourceFile, deleteResourceFile } from '../../../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../../../lib/tokens';
import { lookup } from 'mrmime';
async function checkAuth(request: Request, resource: { 'author-email': string; author: string }): Promise<Response | null> {
if (resource['author-email'] && await hasToken(resource['author-email'])) {
const token = extractBearerToken(request);
const valid = await verifyToken(resource['author-email'], token);
if (!valid) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
return null;
}
export const GET: APIRoute = async ({ params }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
return new Response('Not found', { status: 404 });
}
const data = await getResourceFile(type, slug!, filePath);
if (!data) {
return new Response('Not found', { status: 404 });
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const EXTRA_TEXT: Record<string, string> = {
sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript',
py: 'text/x-python', rb: 'text/x-ruby', go: 'text/x-go', rs: 'text/x-rust',
ts: 'text/typescript', tsx: 'text/typescript', jsx: 'text/javascript',
yml: 'text/yaml', toml: 'text/toml', cfg: 'text/plain', conf: 'text/plain',
ini: 'text/plain', env: 'text/plain', tpl: 'text/plain', tmpl: 'text/plain',
hbs: 'text/plain', ejs: 'text/plain', sql: 'text/sql', txt: 'text/plain',
log: 'text/plain', csv: 'text/csv',
};
const mime = lookup(filePath) || EXTRA_TEXT[ext] || 'application/octet-stream';
return new Response(data, {
headers: { 'Content-Type': mime },
});
};
export const PUT: APIRoute = async ({ params, request }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
return new Response(JSON.stringify({ error: 'Invalid params' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const resource = await getResource(type, slug!);
if (!resource) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const authErr = await checkAuth(request, resource);
if (authErr) return authErr;
try {
const buffer = Buffer.from(await request.arrayBuffer());
await addResourceFile(type, slug!, filePath, buffer);
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
};
export const DELETE: APIRoute = async ({ params, request }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
return new Response(JSON.stringify({ error: 'Invalid params' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const resource = await getResource(type, slug!);
if (!resource) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const authErr = await checkAuth(request, resource);
if (authErr) return authErr;
try {
await deleteResourceFile(type, slug!, filePath);
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -0,0 +1,83 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../../../../lib/registry';
import { listResourceFiles, addResourceFile, getResource } from '../../../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../../../lib/tokens';
export const GET: APIRoute = async ({ params }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const files = await listResourceFiles(type, slug!);
return new Response(JSON.stringify({ files }), {
headers: { 'Content-Type': 'application/json' },
});
};
export const POST: APIRoute = async ({ params, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Auth check
const resource = await getResource(type, slug!);
if (!resource) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
if (resource.format !== 'folder') {
return new Response(JSON.stringify({ error: 'Resource is not a folder' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (resource['author-email'] && await hasToken(resource['author-email'])) {
const token = extractBearerToken(request);
const valid = await verifyToken(resource['author-email'], token);
if (!valid) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const relativePath = formData.get('path') as string | null;
if (!file || !relativePath) {
return new Response(JSON.stringify({ error: 'file and path are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const buffer = Buffer.from(await file.arrayBuffer());
await addResourceFile(type, slug!, relativePath, buffer);
return new Response(JSON.stringify({ ok: true, path: relativePath }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -0,0 +1,91 @@
import type { APIRoute } from 'astro';
import matter from 'gray-matter';
import { isValidResourceType } from '../../../../lib/registry';
import { listResources, createResource, isValidSlug } from '../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../lib/tokens';
import { recordPush } from '../../../../lib/stats';
export const GET: APIRoute = async ({ params }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const resources = await listResources(type);
return new Response(JSON.stringify(resources), {
headers: { 'Content-Type': 'application/json' },
});
};
export const POST: APIRoute = async ({ params, request }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
let body: { slug?: string; content?: string; format?: 'file' | 'folder' };
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { slug, content, format } = body;
if (!slug || !content) {
return new Response(JSON.stringify({ error: 'slug and content are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (!isValidSlug(slug)) {
return new Response(JSON.stringify({ error: 'Invalid slug. Use lowercase alphanumeric and hyphens, 2-64 chars.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const parsed = matter(content);
const authorEmail = (parsed.data['author-email'] as string) || '';
if (authorEmail && await hasToken(authorEmail)) {
const token = extractBearerToken(request);
const valid = await verifyToken(authorEmail, token);
if (!valid) {
return new Response(JSON.stringify({ error: 'Valid token required. Register first via POST /api/auth/register.' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
try {
const resource = await createResource(type, slug, content, format);
recordPush(slug, type);
return new Response(JSON.stringify(resource), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
if (message.includes('already exists')) {
return new Response(JSON.stringify({ error: message }), {
status: 409,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -1,8 +1,9 @@
---
import Base from '../layouts/Base.astro';
import SkillCard from '../components/SkillCard.astro';
import SkillSearch from '../components/SkillSearch.vue';
import { listSkills } from '../lib/skills';
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';
@@ -17,38 +18,62 @@ if (!accept.includes('text/html')) {
});
}
const skills = await listSkills();
// 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;
}
// Compute fork counts and unique authors
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 s of skills) {
if (s['fork-of']) {
forkCounts.set(s['fork-of'], (forkCounts.get(s['fork-of']) || 0) + 1);
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(skills.map(s => s.author).filter(Boolean))].sort();
const allTags = [...new Set(skills.flatMap(s => s.tags))].sort();
const allStats = await getAllStats();
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="Skills">
{skills.length === 0 ? (
<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 skills yet</p>
<p class="text-gray-600 text-sm mb-6">Create your first skill to get started.</p>
<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="/new"
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 skill
Create your first resource
</a>
</div>
) : (
@@ -56,17 +81,19 @@ const allStats = await getAllStats();
<!-- 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">
<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 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> &mdash; 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">
<!-- Quick install -->
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
<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</h2>
<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>
@@ -97,10 +124,9 @@ const allStats = await getAllStats();
</div>
</details>
<!-- Quick push -->
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
<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</h2>
<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>
@@ -118,87 +144,106 @@ const allStats = await getAllStats();
<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 + 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()}
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>
))}
<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="skills-table" class="hidden overflow-x-auto rounded-2xl border border-white/[0.06]">
<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">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 };
{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-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-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">
<a href={`/${skill.slug}`} class="font-medium text-white hover:text-accent-400 transition-colors">{skill.name}</a>
<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">
{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) => (
{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">{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>
<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>
);
})}
@@ -214,7 +259,6 @@ const allStats = await getAllStats();
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
</style>
<script>
// OS detection + tab switching
const isWin = /Win/.test(navigator.platform);
function setOS(os: string) {
document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(el => {
@@ -229,7 +273,6 @@ const allStats = await getAllStats();
tab.addEventListener('click', () => setOS(tab.dataset.os!));
});
// Copy buttons
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const container = btn.parentElement!;
@@ -242,4 +285,15 @@ const allStats = await getAllStats();
}
});
});
// 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>