Initial commit

This commit is contained in:
Alejandro Martinez
2026-02-12 02:04:10 +01:00
commit f09af719cf
13433 changed files with 2193445 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
<template>
<button
@click="handleDelete"
:disabled="deleting"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 bg-red-500/5 px-3.5 py-2 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:border-red-500/30 disabled:opacity-50 active:scale-[0.97] 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>
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ slug: string }>();
const deleting = ref(false);
async function handleDelete() {
if (!confirm(`Delete "${props.slug}"? This cannot be undone.`)) return;
deleting.value = true;
try {
const res = await fetch(`/api/skills/${props.slug}`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) {
throw new Error('Failed to delete');
}
window.location.href = '/';
} catch {
alert('Failed to delete skill.');
deleting.value = false;
}
}
</script>

View File

@@ -0,0 +1,36 @@
---
interface Props {
slug: string;
name: string;
description: string;
'allowed-tools': string[];
}
const { slug, name, description, 'allowed-tools': allowedTools } = Astro.props;
const truncated = description.length > 120 ? description.slice(0, 120) + '...' : description;
---
<a
href={`/${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">
<h2 class="text-[15px] font-semibold text-white group-hover:text-accent-400 transition-colors">{name}</h2>
<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-4">{truncated}</p>}
{allowedTools.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{allowedTools.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>
)}
</div>
</a>

View File

@@ -0,0 +1,306 @@
<template>
<form @submit.prevent="save" class="space-y-6">
<!-- Basic -->
<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
placeholder="My Awesome Skill"
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">
Slug: <code class="text-gray-500 font-mono">{{ computedSlug }}</code>
</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"
placeholder="Brief description of what this skill 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"
/>
</div>
</div>
<!-- Allowed Tools -->
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Allowed Tools</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="tool in AVAILABLE_TOOLS"
:key="tool"
type="button"
@click="toggleTool(tool)"
:class="[
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
selectedTools.has(tool)
? '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]'
]"
>
{{ tool }}
</button>
</div>
</div>
<!-- Behavior -->
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Model</label>
<select
v-model="model"
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 value="">Default</option>
<option v-for="m in AVAILABLE_MODELS" :key="m.id" :value="m.id">{{ m.display_name }}</option>
</select>
</div>
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Argument Hint</label>
<input
v-model="argumentHint"
type="text"
placeholder="e.g. &lt;file-path&gt;"
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.5">Agent</label>
<select
v-model="agent"
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 value="">general-purpose (default)</option>
<option value="Explore">Explore</option>
<option value="Plan">Plan</option>
</select>
</div>
</div>
<!-- Toggles -->
<div class="flex flex-wrap gap-6">
<label class="flex items-center gap-2.5 cursor-pointer group">
<input type="checkbox" v-model="userInvocable" 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">User Invocable <span class="text-gray-600">(show in /menu)</span></span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer group">
<input type="checkbox" v-model="disableModelInvocation" 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">Disable Model Invocation <span class="text-gray-600">(manual only)</span></span>
</label>
</div>
<!-- Context -->
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Context</label>
<select
v-model="context"
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 value="">Inline (default)</option>
<option value="fork">Fork (run in subagent)</option>
</select>
<p class="mt-1.5 text-xs text-gray-600">Fork runs the skill in an isolated subagent context</p>
</div>
<!-- Hooks (advanced) -->
<details class="group">
<summary class="text-xs font-medium uppercase tracking-wider text-gray-500 cursor-pointer hover:text-gray-400 transition-colors">Hooks (advanced)</summary>
<div class="mt-3">
<textarea
v-model="hooksJson"
rows="4"
placeholder='{ "preToolExecution": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo pre" }] }] }'
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 class="mt-1.5 text-xs text-gray-600">JSON object. Leave empty to omit.</p>
</div>
</details>
<!-- Body + Preview -->
<div class="grid gap-4 lg:grid-cols-2">
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Skill Body</label>
<textarea
v-model="body"
rows="20"
placeholder="# My Skill&#10;&#10;Instructions for Claude..."
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>
<div class="flex items-center gap-4 pt-2">
<button
type="submit"
:disabled="saving"
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...' : (mode === 'create' ? 'Create Skill' : '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 } from 'vue';
import { marked } from 'marked';
const props = defineProps<{
mode: 'create' | 'edit';
slug?: string;
initialName?: string;
initialDescription?: string;
initialAllowedTools?: string;
initialArgumentHint?: string;
initialModel?: string;
initialUserInvocable?: boolean;
initialDisableModelInvocation?: boolean;
initialContext?: string;
initialAgent?: string;
initialHooks?: string;
initialBody?: string;
availableTools?: string[];
availableModels?: Array<{ id: string; display_name: string }>;
}>();
const AVAILABLE_TOOLS = props.availableTools ?? [
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebFetch', 'WebSearch', 'Task', 'NotebookEdit',
];
const AVAILABLE_MODELS = props.availableModels ?? [
{ id: 'claude-opus-4-6', display_name: 'Claude Opus 4.6' },
{ id: 'claude-sonnet-4-5-20250929', display_name: 'Claude Sonnet 4.5' },
{ id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5' },
];
const name = ref(props.initialName || '');
const description = ref(props.initialDescription || '');
const argumentHint = ref(props.initialArgumentHint || '');
const model = ref(props.initialModel || '');
const userInvocable = ref(props.initialUserInvocable ?? true);
const disableModelInvocation = ref(props.initialDisableModelInvocation ?? false);
const context = ref(props.initialContext || '');
const agent = ref(props.initialAgent || '');
const hooksJson = ref(props.initialHooks || '');
const body = ref(props.initialBody || '');
const saving = ref(false);
const error = ref('');
const selectedTools = ref(new Set<string>(
props.initialAllowedTools
? props.initialAllowedTools.split(',').map(t => t.trim()).filter(Boolean)
: []
));
function toggleTool(tool: string) {
if (selectedTools.value.has(tool)) {
selectedTools.value.delete(tool);
} else {
selectedTools.value.add(tool);
}
selectedTools.value = new Set(selectedTools.value);
}
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-skill';
});
let previewHtml = ref('');
let debounceTimer: ReturnType<typeof setTimeout>;
watch(body, (val) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
previewHtml.value = await marked(val || '');
}, 300);
}, { immediate: true });
function buildContent(): string {
const tools = [...selectedTools.value];
const lines: string[] = ['---'];
// Official Claude Code skill frontmatter format (kebab-case, comma-separated tools)
lines.push(`name: ${name.value}`);
if (description.value) lines.push(`description: ${description.value}`);
if (argumentHint.value) lines.push(`argument-hint: ${argumentHint.value}`);
if (tools.length > 0) lines.push(`allowed-tools: ${tools.join(', ')}`);
if (model.value) lines.push(`model: ${model.value}`);
if (userInvocable.value === false) lines.push('user-invocable: false');
if (disableModelInvocation.value) lines.push('disable-model-invocation: true');
if (context.value) lines.push(`context: ${context.value}`);
if (agent.value) lines.push(`agent: ${agent.value}`);
if (hooksJson.value.trim()) {
try {
const parsed = JSON.parse(hooksJson.value.trim());
lines.push(`hooks: ${JSON.stringify(parsed)}`);
} catch {
// skip invalid JSON
}
}
lines.push('---');
return lines.join('\n') + '\n\n' + body.value.trim() + '\n';
}
async function save() {
saving.value = true;
error.value = '';
try {
const content = buildContent();
if (props.mode === 'create') {
const res = await fetch('/api/skills', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: computedSlug.value, content }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to create skill');
}
window.location.href = `/${computedSlug.value}`;
} else {
const res = await fetch(`/api/skills/${props.slug}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to update skill');
}
window.location.href = `/${props.slug}`;
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong';
} finally {
saving.value = false;
}
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="mb-6 max-w-md">
<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="Search skills..."
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>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
const query = ref('');
watch(query, (val) => {
const q = val.toLowerCase().trim();
const cards = document.querySelectorAll<HTMLElement>('[data-skill]');
cards.forEach((card) => {
const name = card.dataset.name || '';
const desc = card.dataset.description || '';
const tools = card.dataset.tools || '';
const match = !q || name.includes(q) || desc.includes(q) || tools.includes(q);
card.style.display = match ? '' : 'none';
});
});
</script>

1
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

53
src/layouts/Base.astro Normal file
View File

@@ -0,0 +1,53 @@
---
import '../styles/global.css';
interface Props {
title?: string;
}
const { title = 'Skillit' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>{title}</title>
</head>
<body class="min-h-screen font-sans text-gray-300 antialiased">
<!-- Subtle gradient glow -->
<div class="pointer-events-none fixed inset-0 overflow-hidden">
<div class="absolute -top-40 left-1/2 -translate-x-1/2 h-80 w-[600px] rounded-full bg-accent-500/[0.07] blur-[120px]"></div>
</div>
<nav class="relative z-50 border-b border-white/[0.06] bg-surface-50/80 backdrop-blur-xl">
<div class="mx-auto max-w-5xl flex items-center justify-between px-6 py-4">
<a href="/" class="group flex items-center gap-2.5">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-accent-500 to-accent-600 shadow-lg shadow-accent-500/20">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">skillit</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
</a>
</div>
</nav>
<main class="relative mx-auto max-w-5xl px-6 py-10">
<slot />
</main>
</body>
</html>

118
src/lib/models.ts Normal file
View File

@@ -0,0 +1,118 @@
export interface ClaudeModel {
id: string;
display_name: string;
}
const FALLBACK_MODELS: ClaudeModel[] = [
{ id: 'claude-opus-4-6', display_name: 'Claude Opus 4.6' },
{ id: 'claude-sonnet-4-5-20250929', display_name: 'Claude Sonnet 4.5' },
{ id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5' },
];
const DOCS_URL = 'https://platform.claude.com/docs/en/about-claude/models/overview';
// Matches Claude API IDs like claude-opus-4-6, claude-sonnet-4-5-20250929, claude-3-haiku-20240307
const MODEL_ID_RE = /claude-(?:opus|sonnet|haiku|3-\d+-(?:opus|sonnet|haiku)|3-(?:opus|sonnet|haiku))-[\w-]*\d+(?:-\d{8})?/g;
let cached: ClaudeModel[] | null = null;
let lastFetch = 0;
const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
function displayName(id: string): string {
// claude-opus-4-6 → Claude Opus 4.6
// claude-sonnet-4-5-20250929 → Claude Sonnet 4.5
// claude-3-haiku-20240307 → Claude 3 Haiku
const clean = id
.replace(/-\d{8}$/, '') // remove date suffix
.replace(/^claude-/, '');
const parts = clean.split('-');
const words = parts.map((p, i) => {
if (/^\d+$/.test(p) && i === parts.length - 1 && parts.length > 1) {
// Last numeric part after a name: turn "4-6" into "4.6" or "4-5" into "4.5"
const prev = parts[i - 1];
if (/^\d+$/.test(prev)) return null; // will be joined with prev
return p;
}
return p.charAt(0).toUpperCase() + p.slice(1);
}).filter(Boolean);
// Join consecutive numbers with dots: ["4", "6"] → "4.6"
const result: string[] = [];
for (const w of words) {
if (/^\d+$/.test(w!) && result.length > 0 && /^\d+$/.test(result[result.length - 1])) {
result[result.length - 1] += '.' + w;
} else {
result.push(w!);
}
}
return 'Claude ' + result.join(' ');
}
export async function getAvailableModels(): Promise<ClaudeModel[]> {
if (cached && Date.now() - lastFetch < CACHE_TTL) {
return cached;
}
// Try API first if key available
const apiKey = process.env.ANTHROPIC_API_KEY;
if (apiKey) {
try {
const res = await fetch('https://api.anthropic.com/v1/models?limit=100', {
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const body = await res.json() as { data: Array<{ id: string; display_name: string }> };
const models = body.data
.filter((m) => m.id.startsWith('claude-'))
.map((m) => ({ id: m.id, display_name: m.display_name }));
if (models.length > 0) {
cached = models;
lastFetch = Date.now();
return cached;
}
}
} catch { /* fall through */ }
}
// Scrape public docs page (no auth needed)
try {
const res = await fetch(DOCS_URL, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
const seen = new Set<string>();
const models: ClaudeModel[] = [];
for (const match of html.matchAll(MODEL_ID_RE)) {
const id = match[0];
// Skip AWS/GCP versioned IDs
if (id.endsWith('-v1') || id.includes('-v1:')) continue;
if (seen.has(id)) continue;
seen.add(id);
models.push({ id, display_name: displayName(id) });
}
// Deduplicate: prefer shortest alias per model family
const deduped = new Map<string, ClaudeModel>();
for (const m of models) {
// Normalize: remove date and trailing -0 alias suffix
const base = m.id.replace(/-\d{8}$/, '').replace(/-0$/, '');
if (!deduped.has(base) || m.id.length < deduped.get(base)!.id.length) {
deduped.set(base, m);
}
}
const result = [...deduped.values()];
if (result.length > 0) {
cached = result;
lastFetch = Date.now();
return cached;
}
} catch { /* fall through */ }
cached = FALLBACK_MODELS;
lastFetch = Date.now();
return cached;
}

133
src/lib/skills.ts Normal file
View File

@@ -0,0 +1,133 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
export const SKILLS_DIR = path.resolve(
process.env.SKILLS_DIR || 'data/skills'
);
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SLUG_LENGTH = 64;
export interface SkillMeta {
slug: string;
name: string;
description: string;
'allowed-tools': string[];
'argument-hint': string;
model: string;
'user-invocable': boolean;
'disable-model-invocation': boolean;
context: string;
agent: string;
hooks: Record<string, unknown> | null;
}
export interface Skill extends SkillMeta {
content: string;
raw: string;
}
export function isValidSlug(slug: string): boolean {
return slug.length >= 2 && slug.length <= MAX_SLUG_LENGTH && SLUG_RE.test(slug);
}
function skillPath(slug: string): string {
return path.join(SKILLS_DIR, `${slug}.md`);
}
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 [];
}
function parseSkill(slug: string, raw: string): Skill {
const { data, content } = matter(raw);
return {
slug,
name: (data.name as string) || slug,
description: (data.description as string) || '',
'allowed-tools': parseTools(data['allowed-tools'] ?? data.allowedTools),
'argument-hint': (data['argument-hint'] as string) || '',
model: (data.model as string) || '',
'user-invocable': data['user-invocable'] !== false,
'disable-model-invocation': Boolean(data['disable-model-invocation']),
context: (data.context as string) || '',
agent: (data.agent as string) || '',
hooks: (typeof data.hooks === 'object' && data.hooks !== null) ? data.hooks as Record<string, unknown> : null,
content: content.trim(),
raw,
};
}
export async function listSkills(): Promise<SkillMeta[]> {
await fs.mkdir(SKILLS_DIR, { recursive: true });
const files = await fs.readdir(SKILLS_DIR);
const skills: SkillMeta[] = [];
for (const file of files) {
if (!file.endsWith('.md')) continue;
const slug = file.replace(/\.md$/, '');
const raw = await fs.readFile(path.join(SKILLS_DIR, file), 'utf-8');
const { content: _, raw: __, ...meta } = parseSkill(slug, raw);
skills.push(meta);
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
export async function getSkill(slug: string): Promise<Skill | null> {
try {
const raw = await fs.readFile(skillPath(slug), 'utf-8');
return parseSkill(slug, raw);
} catch {
return null;
}
}
export async function createSkill(slug: string, content: string): Promise<Skill> {
if (!isValidSlug(slug)) {
throw new Error(`Invalid slug: ${slug}`);
}
await fs.mkdir(SKILLS_DIR, { recursive: true });
const dest = skillPath(slug);
try {
await fs.access(dest);
throw new Error(`Skill already exists: ${slug}`);
} catch (err) {
if (err instanceof Error && err.message.startsWith('Skill already exists')) {
throw err;
}
}
await fs.writeFile(dest, content, 'utf-8');
return parseSkill(slug, content);
}
export async function updateSkill(slug: string, content: string): Promise<Skill> {
const dest = skillPath(slug);
try {
await fs.access(dest);
} catch {
throw new Error(`Skill not found: ${slug}`);
}
await fs.writeFile(dest, content, 'utf-8');
return parseSkill(slug, content);
}
export async function deleteSkill(slug: string): Promise<void> {
const dest = skillPath(slug);
try {
await fs.access(dest);
} catch {
throw new Error(`Skill not found: ${slug}`);
}
await fs.unlink(dest);
}

76
src/lib/sync.ts Normal file
View File

@@ -0,0 +1,76 @@
import { listSkills } from './skills';
export async function buildPushScript(baseUrl: string, skillsDir: string): Promise<string> {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SKILLS_DIR="${skillsDir}"`,
`BASE_URL="${baseUrl}"`,
'',
'if [ ! -d "$SKILLS_DIR" ]; then',
' echo "No skills directory found at $SKILLS_DIR"',
' exit 1',
'fi',
'',
'count=0',
'for file in "$SKILLS_DIR"/*.md; do',
' [ -f "$file" ] || continue',
' slug=$(basename "$file" .md)',
' content=$(cat "$file")',
'',
' # Try PUT (update), fallback to POST (create)',
' status=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
'',
' if [ "$status" = "404" ]; then',
' curl -fsS -X POST \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills" > /dev/null',
' fi',
'',
' echo " ✓ $slug"',
' count=$((count + 1))',
'done',
'',
'echo "Pushed $count skill(s) to $BASE_URL"',
'',
];
return lines.join('\n');
}
export async function buildSyncScript(baseUrl: string, skillsDir: string): Promise<string> {
const skills = await listSkills();
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SKILLS_DIR="${skillsDir}"`,
'mkdir -p "$SKILLS_DIR"',
'',
];
if (skills.length === 0) {
lines.push('echo "No skills available to sync."');
} else {
lines.push(`echo "Syncing ${skills.length} skill(s) 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}"`);
}
lines.push('');
lines.push('echo "Done! Skills synced to $SKILLS_DIR"');
}
lines.push('');
return lines.join('\n');
}

47
src/lib/tools.ts Normal file
View File

@@ -0,0 +1,47 @@
const FALLBACK_TOOLS = [
'Bash', 'Read', 'Write', 'Edit', 'MultiEdit', 'Glob', 'Grep',
'WebFetch', 'WebSearch', 'Task', 'NotebookEdit', 'NotebookRead',
'TodoRead', 'TodoWrite',
];
const GIST_URL = 'https://gist.githubusercontent.com/wong2/e0f34aac66caf890a332f7b6f9e2ba8f/raw';
// Internal tools not useful for skills
const IGNORED = new Set(['LS', 'exit_plan_mode', 'Agent', 'BashOutput', 'KillShell', 'SlashCommand', 'ExitPlanMode']);
let cached: string[] | null = null;
let lastFetch = 0;
const CACHE_TTL = 1000 * 60 * 60; // 1 hour
export async function getAvailableTools(): Promise<string[]> {
if (cached && Date.now() - lastFetch < CACHE_TTL) {
return cached;
}
try {
const res = await fetch(GIST_URL, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
// Extract function names from JSON schema blocks: "name": "ToolName"
const matches = text.matchAll(/"name":\s*"([A-Z][a-zA-Z]+)"/g);
const tools = new Set<string>();
for (const m of matches) {
if (!IGNORED.has(m[1])) {
tools.add(m[1]);
}
}
if (tools.size >= 5) {
cached = [...tools].sort();
lastFetch = Date.now();
return cached;
}
} catch {
// Scrape failed, use fallback
}
cached = FALLBACK_TOOLS;
lastFetch = Date.now();
return cached;
}

98
src/pages/[slug].astro Normal file
View File

@@ -0,0 +1,98 @@
---
import Base from '../layouts/Base.astro';
import DeleteButton from '../components/DeleteButton.vue';
import { getSkill } from '../lib/skills';
import { marked } from 'marked';
const { slug } = Astro.params;
const skill = await getSkill(slug!);
if (!skill) {
return Astro.redirect('/');
}
// curl / wget → raw markdown
const accept = Astro.request.headers.get('accept') || '';
if (!accept.includes('text/html')) {
return new Response(skill.raw, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
}
const html = await marked(skill.content);
const installCmd = `curl -fsSL ${Astro.url.origin}/${slug} -o .claude/skills/${slug}.md`;
---
<Base title={`${skill.name} — Skillit`}>
<div class="mb-8">
<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
</a>
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight text-white">{skill.name}</h1>
{skill.description && <p class="text-gray-500 mt-1.5 leading-relaxed">{skill.description}</p>}
</div>
<div class="flex items-center gap-2 shrink-0">
<a
href={`/${slug}/edit`}
class="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.08] bg-surface-200 px-3.5 py-2 text-sm font-medium text-gray-300 hover:border-white/[0.15] hover:text-white 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="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>
Edit
</a>
<DeleteButton slug={slug!} client:load />
</div>
</div>
</div>
{skill['allowed-tools'].length > 0 && (
<div class="flex flex-wrap gap-1.5 mb-8">
{skill['allowed-tools'].map((tool) => (
<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>
)}
<!-- Install & usage -->
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 mb-8 max-w-2xl space-y-4">
<h2 class="text-sm font-semibold text-white">Install this skill</h2>
<p class="text-xs text-gray-500 leading-relaxed">Run this in your project root. The skill file will be saved to <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/{slug}.md</code> and Claude Code will load it automatically.</p>
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
<code class="flex-1 text-xs font-mono text-gray-500 select-all truncate">{installCmd}</code>
<button
id="copy-install"
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>
{skill['allowed-tools'].length > 0 && (
<p class="text-xs text-gray-600 leading-relaxed">This skill uses: {skill['allowed-tools'].join(', ')}. Claude will have access to these tools when this skill is active.</p>
)}
</div>
<article class="skill-prose rounded-2xl border border-white/[0.06] bg-surface-100 p-8" set:html={html} />
</Base>
<script>
const btn = document.getElementById('copy-install');
if (btn) {
btn.addEventListener('click', () => {
const code = btn.previousElementSibling?.textContent?.trim();
if (code) {
navigator.clipboard.writeText(code);
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
}
});
}
</script>

View File

@@ -0,0 +1,48 @@
---
import Base from '../../layouts/Base.astro';
import SkillEditor from '../../components/SkillEditor.vue';
import { getSkill } from '../../lib/skills';
import { getAvailableTools } from '../../lib/tools';
import { getAvailableModels } from '../../lib/models';
const { slug } = Astro.params;
const skill = await getSkill(slug!);
if (!skill) {
return Astro.redirect('/');
}
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
const allowedTools = skill['allowed-tools'].join(', ');
const hooksJson = skill.hooks ? JSON.stringify(skill.hooks, null, 2) : '';
---
<Base title={`Edit ${skill.name} — Skillit`}>
<a href={`/${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 {skill.name}
</a>
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">Edit Skill</h1>
<p class="text-sm text-gray-500 mb-8">Editing <strong class="text-gray-400">{skill.name}</strong>. Users who already installed this skill will get the updated version on their next sync.</p>
<SkillEditor
mode="edit"
slug={slug}
initialName={skill.name}
initialDescription={skill.description}
initialAllowedTools={allowedTools}
initialArgumentHint={skill['argument-hint']}
initialModel={skill.model}
initialUserInvocable={skill['user-invocable']}
initialDisableModelInvocation={skill['disable-model-invocation']}
initialContext={skill.context}
initialAgent={skill.agent}
initialHooks={hooksJson}
initialBody={skill.content}
:availableTools={availableTools}
:availableModels={availableModels}
client:load
/>
</Base>

View File

@@ -0,0 +1,69 @@
import type { APIRoute } from 'astro';
import { getSkill, updateSkill, deleteSkill } from '../../../lib/skills';
export const GET: APIRoute = async ({ params }) => {
const skill = await getSkill(params.slug!);
if (!skill) {
return new Response('Not found', { status: 404 });
}
return new Response(skill.raw, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
};
export const PUT: APIRoute = async ({ params, request }) => {
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 skill = await updateSkill(params.slug!, body.content);
return new Response(JSON.stringify(skill), {
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
if (message.includes('not found')) {
return new Response(JSON.stringify({ error: message }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};
export const DELETE: APIRoute = async ({ params }) => {
try {
await deleteSkill(params.slug!);
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
if (message.includes('not found')) {
return new Response(JSON.stringify({ error: message }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -0,0 +1,57 @@
import type { APIRoute } from 'astro';
import { listSkills, createSkill, isValidSlug } from '../../../lib/skills';
export const GET: APIRoute = async () => {
const skills = await listSkills();
return new Response(JSON.stringify(skills), {
headers: { 'Content-Type': 'application/json' },
});
};
export const POST: APIRoute = async ({ request }) => {
let body: { slug?: string; content?: string };
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { slug, content } = 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' },
});
}
try {
const skill = await createSkill(slug, content);
return new Response(JSON.stringify(skill), {
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

@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { buildSyncScript } from '../../../lib/sync';
export const GET: APIRoute = async ({ url }) => {
const script = await buildSyncScript(url.origin, '$HOME/.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

View File

@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { buildSyncScript } from '../../../lib/sync';
export const GET: APIRoute = async ({ url }) => {
const script = await buildSyncScript(url.origin, '.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

9
src/pages/gi.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { buildSyncScript } from '../lib/sync';
export const GET: APIRoute = async ({ url }) => {
const script = await buildSyncScript(url.origin, '$HOME/.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

9
src/pages/gp.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { buildPushScript } from '../lib/sync';
export const GET: APIRoute = async ({ url }) => {
const script = await buildPushScript(url.origin, '$HOME/.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

9
src/pages/i.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { buildSyncScript } from '../lib/sync';
export const GET: APIRoute = async ({ url }) => {
const script = await buildSyncScript(url.origin, '.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

107
src/pages/index.astro Normal file
View File

@@ -0,0 +1,107 @@
---
import Base from '../layouts/Base.astro';
import SkillCard from '../components/SkillCard.astro';
import SkillSearch from '../components/SkillSearch.vue';
import { listSkills } from '../lib/skills';
import { buildSyncScript } from '../lib/sync';
const accept = Astro.request.headers.get('accept') || '';
if (!accept.includes('text/html')) {
const script = await buildSyncScript(Astro.url.origin, '.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
const skills = await listSkills();
---
<Base title="Skillit — Claude Code Skills">
{skills.length === 0 ? (
<div class="text-center py-24">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-200 border border-white/[0.06] mb-6">
<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>
<a
href="/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
</a>
</div>
) : (
<div>
<!-- Hero / Quick install -->
<div class="mb-10">
<h1 class="text-3xl font-extrabold tracking-tight text-white mb-2">Skills</h1>
<p class="text-gray-500 mb-5 max-w-xl">Manage and distribute Claude Code skills. Skills are prompt files that Claude loads automatically to learn custom behaviors and workflows.</p>
<!-- Install instructions -->
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 max-w-2xl space-y-4">
<h2 class="text-sm font-semibold text-white">Quick install</h2>
<p class="text-xs text-gray-500 leading-relaxed">Run this in your project root to sync all skills. They'll be saved to <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/</code> and Claude Code will pick them up automatically on the next conversation.</p>
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
<code id="install-cmd" class="flex-1 text-sm font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin} | bash</code>
<button
id="copy-btn"
class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all"
>
Copy
</button>
</div>
<details class="group">
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">More options</summary>
<div class="mt-3 space-y-3 text-xs text-gray-500">
<div>
<p class="mb-1.5">Install globally (available in all projects):</p>
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
<code class="flex-1 font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/gi | bash</code>
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
</div>
<div>
<p class="mb-1.5">Push local skills to the server:</p>
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
<code class="flex-1 font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/p | bash</code>
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
</div>
</div>
</details>
</div>
</div>
<!-- Search + Grid -->
<SkillSearch client:load />
<div id="skills-grid" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{skills.map((skill) => (
<div data-skill data-name={skill.name.toLowerCase()} data-description={skill.description.toLowerCase()} data-tools={skill['allowed-tools'].join(' ').toLowerCase()}>
<SkillCard {...skill} />
</div>
))}
</div>
</div>
)}
</Base>
<script>
document.querySelectorAll<HTMLButtonElement>('#copy-btn, [data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const code = btn.previousElementSibling?.textContent?.trim();
if (code) {
navigator.clipboard.writeText(code);
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
}
});
});
</script>

21
src/pages/new.astro Normal file
View File

@@ -0,0 +1,21 @@
---
import Base from '../layouts/Base.astro';
import SkillEditor from '../components/SkillEditor.vue';
import { getAvailableTools } from '../lib/tools';
import { getAvailableModels } from '../lib/models';
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
---
<Base title="New Skill — Skillit">
<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
</a>
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">New Skill</h1>
<p class="text-sm text-gray-500 mb-8 max-w-xl">Write a prompt in Markdown that tells Claude how to behave. The <strong class="text-gray-400">body</strong> is the instruction Claude receives. Use <strong class="text-gray-400">Allowed Tools</strong> to restrict which tools the skill can use.</p>
<SkillEditor mode="create" :availableTools={availableTools} :availableModels={availableModels} client:load />
</Base>

9
src/pages/p.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { buildPushScript } from '../lib/sync';
export const GET: APIRoute = async ({ url }) => {
const script = await buildPushScript(url.origin, '.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

36
src/styles/global.css Normal file
View File

@@ -0,0 +1,36 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--color-accent-400: #fb923c;
--color-accent-500: #f97316;
--color-accent-600: #ea580c;
--color-surface-50: #0a0a0f;
--color-surface-100: #12121a;
--color-surface-200: #1a1a25;
--color-surface-300: #252530;
--color-surface-400: #35354a;
}
body {
background-color: var(--color-surface-50);
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
::selection {
background-color: rgb(249 115 22 / 0.3);
}
/* Prose overrides for markdown content */
.skill-prose h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; color: white; }
.skill-prose h2 { font-size: 1.2rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; color: #e5e5e5; }
.skill-prose h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.5rem; color: #d4d4d4; }
.skill-prose p { margin-bottom: 0.75rem; color: #a3a3a3; line-height: 1.7; }
.skill-prose ul, .skill-prose ol { margin-bottom: 0.75rem; padding-left: 1.5rem; color: #a3a3a3; }
.skill-prose li { margin-bottom: 0.25rem; line-height: 1.6; }
.skill-prose code { background: rgb(255 255 255 / 0.06); padding: 0.15rem 0.4rem; border-radius: 0.25rem; font-size: 0.85em; color: #fb923c; }
.skill-prose pre { background: rgb(0 0 0 / 0.4); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-bottom: 1rem; border: 1px solid rgb(255 255 255 / 0.06); }
.skill-prose pre code { background: none; padding: 0; color: #d4d4d4; }
.skill-prose a { color: #fb923c; text-decoration: underline; text-underline-offset: 2px; }
.skill-prose blockquote { border-left: 3px solid #f97316; padding-left: 1rem; color: #737373; font-style: italic; }