Initial commit
This commit is contained in:
35
src/components/DeleteButton.vue
Normal file
35
src/components/DeleteButton.vue
Normal 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>
|
||||
36
src/components/SkillCard.astro
Normal file
36
src/components/SkillCard.astro
Normal 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>
|
||||
306
src/components/SkillEditor.vue
Normal file
306
src/components/SkillEditor.vue
Normal 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. <file-path>"
|
||||
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 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>
|
||||
33
src/components/SkillSearch.vue
Normal file
33
src/components/SkillSearch.vue
Normal 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
1
src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
53
src/layouts/Base.astro
Normal file
53
src/layouts/Base.astro
Normal 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
118
src/lib/models.ts
Normal 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
133
src/lib/skills.ts
Normal 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
76
src/lib/sync.ts
Normal 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
47
src/lib/tools.ts
Normal 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
98
src/pages/[slug].astro
Normal 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>
|
||||
48
src/pages/[slug]/edit.astro
Normal file
48
src/pages/[slug]/edit.astro
Normal 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>
|
||||
69
src/pages/api/skills/[slug].ts
Normal file
69
src/pages/api/skills/[slug].ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
};
|
||||
57
src/pages/api/skills/index.ts
Normal file
57
src/pages/api/skills/index.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
};
|
||||
9
src/pages/api/sync/index.ts
Normal file
9
src/pages/api/sync/index.ts
Normal 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/api/sync/project.ts
Normal file
9
src/pages/api/sync/project.ts
Normal 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
9
src/pages/gi.ts
Normal 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
9
src/pages/gp.ts
Normal 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
9
src/pages/i.ts
Normal 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
107
src/pages/index.astro
Normal 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
21
src/pages/new.astro
Normal 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
9
src/pages/p.ts
Normal 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
36
src/styles/global.css
Normal 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; }
|
||||
Reference in New Issue
Block a user