Compare commits

...

3 Commits

Author SHA1 Message Date
Alejandro Martinez
b86c9f3e3a Add hooks and CLAUDE.md resource types with install/uninstall scripts
Introduces two new resource types (hooks, claude-md) with full CRUD,
   visual hook config editor, section-delimited CLAUDE.md installs,
   uninstall endpoints, and shell injection hardening in sync scripts.
2026-02-16 11:51:33 +01:00
Alejandro Martinez
c1a9442868 Add remaining source changes: resource data, refactored pages, updated styles
- Add example resources (agent, output-style, rule, skill)
   - Refactor legacy skill pages to use generic resource system
   - Update favicon, global styles, models, skills lib, and stats
   - Update PLAN.md
2026-02-13 14:28:26 +01:00
Alejandro Martinez
17423fb3b9 Rename to Grimoired, update domain to grimoi.red, add resource system
- Rename Grimaired -> Grimoired everywhere (title, nav, descriptions, token keys)
- Update domain from skills.here.run.place to grimoi.red
- Add Grimoired logo with description on homepage
- Add accordion behavior for Quick install / Quick push sections
- Add generic resource system (skills, agents, output-styles, rules)
- Add resource registry, editor, search, and file manager components
2026-02-13 14:25:07 +01:00
105 changed files with 6826 additions and 13221 deletions

View File

@@ -1 +1 @@
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.17.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://skills.here.run.place\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":false,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/dev\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[]},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Users/alex/projects/skillit/node_modules/.astro/sessions\"}}}"]
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.17.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://grimoi.red\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":false,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/dev\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[]},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Users/alex/projects/skillit/node_modules/.astro/sessions\"}}}"]

View File

@@ -8,7 +8,11 @@
"WebFetch(domain:astro.build)",
"Bash(npm init:*)",
"Bash(npm install:*)",
"Bash(npx astro check:*)"
"Bash(npx astro check:*)",
"Bash(node -e:*)",
"Bash(ls:*)",
"Bash(npx tsc:*)",
"mcp__ide__getDiagnostics"
]
}
}

View File

@@ -0,0 +1,10 @@
---
name: testeo
description: Un test de skill
tags: test
allowed-tools: Bash, Read, Write, Edit
model: claude-opus-4-6
agent: Plan
---
# Es solo un prueba

View File

@@ -0,0 +1 @@
template

View File

@@ -0,0 +1 @@
referencia

View File

@@ -0,0 +1 @@
bash

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/
data/tokens.json
dist/

View File

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

368
PLAN-HOOKS.md Normal file
View File

@@ -0,0 +1,368 @@
# Plan: Hooks como nuevo tipo de recurso
## Contexto
Claude Code soporta **hooks**: comandos que se ejecutan automaticamente en respuesta a eventos del ciclo de vida del agente. Se configuran en `.claude/settings.json` bajo la clave `hooks`.
### Estructura de hooks en Claude Code
```json
// .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "echo 'About to run bash'" }
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{ "type": "command", "command": "./scripts/lint.sh $CLAUDE_FILE_PATHS" }
]
}
]
}
}
```
### Eventos disponibles (14)
| Evento | Cuando se dispara |
|--------|-------------------|
| `PreToolUse` | Antes de ejecutar una herramienta (puede bloquear) |
| `PostToolUse` | Despues de ejecutar una herramienta |
| `Notification` | Cuando Claude envia una notificacion |
| `Stop` | Cuando Claude termina de responder |
| `SubagentStop` | Cuando un subagente termina |
| `PreCompact` | Antes de compactar contexto |
| `PostCompact` | Despues de compactar contexto |
### Tipos de handler
| Tipo | Descripcion |
|------|-------------|
| `command` | Ejecuta un comando shell. Variables de entorno disponibles: `$CLAUDE_TOOL_NAME`, `$CLAUDE_FILE_PATHS`, `$CLAUDE_TOOL_INPUT`, etc. |
| `prompt` | Inyecta un prompt adicional a Claude |
| `agent` | Invoca un subagente para evaluar |
### Variables de entorno en hooks
- `CLAUDE_TOOL_NAME` — nombre de la herramienta
- `CLAUDE_TOOL_INPUT` — JSON con los parametros de la herramienta
- `CLAUDE_FILE_PATHS` — rutas de archivos separadas por newline
- `CLAUDE_TOOL_OUTPUT` — salida de la herramienta (solo en PostToolUse)
- `CLAUDE_NOTIFICATION` — texto de notificacion (solo en Notification)
- `CLAUDE_TRANSCRIPT` — transcripcion de la conversacion (solo en Stop/SubagentStop)
## Modelo de datos
Un hook en Grimoired se almacena como **carpeta** en `data/hooks/<slug>/`:
```
my-hook/
├── HOOK.md # Documentacion del hook (frontmatter + descripcion)
├── hooks.json # Configuracion JSON del hook (lo que se inyecta en settings)
└── scripts/ # Scripts opcionales referenciados por el hook
├── lint.sh
└── validate.py
```
### HOOK.md (frontmatter)
```yaml
---
name: Auto Lint
description: Ejecuta linting automatico despues de cada escritura de archivo
tags:
- linting
- quality
author: alex
author-email: alex@example.com
events:
- PostToolUse
---
# Auto Lint
Este hook ejecuta automaticamente el linter despues de que Claude escribe o edita un archivo...
```
### hooks.json
```json
{
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/auto-lint/scripts/lint.sh $CLAUDE_FILE_PATHS"
}
]
}
]
}
```
> **Nota**: Los paths en `command` se reescriben durante la instalacion para apuntar a la ubicacion correcta en `.claude/hooks/<slug>/`.
## Archivos a modificar
| Archivo | Cambio |
|---------|--------|
| `src/lib/registry.ts` | Agregar tipo `hooks` al registro con sus campos |
| `src/lib/resources.ts` | Sin cambios — ya soporta formato carpeta generico |
| `src/pages/[type]/[slug].astro` | Renderizar preview de hooks.json y info de eventos |
| `src/pages/[type]/[slug]/i.ts` | Script de instalacion especial para hooks |
| `src/pages/[type]/[slug]/gi.ts` | Script de instalacion global para hooks |
| `src/lib/sync.ts` | Sin cambios mayores — sync/push generico ya funciona |
## Archivos nuevos
| Archivo | Proposito |
|---------|-----------|
| `src/components/HookConfigPreview.astro` | Preview visual del hooks.json en pagina de detalle |
| `src/components/HookConfigEditor.vue` | Editor visual de hooks.json (formulario, no JSON crudo) |
## Fases de implementacion
### Fase 1: Registry — agregar tipo `hooks`
Agregar `'hooks'` a `RESOURCE_TYPES` y su configuracion al `REGISTRY`:
```typescript
export const RESOURCE_TYPES = ['skills', 'agents', 'output-styles', 'rules', 'hooks'] as const;
// En REGISTRY:
hooks: {
slug: 'hooks',
label: 'Hooks',
labelSingular: 'Hook',
claudeDir: 'hooks',
dataDir: path.resolve(process.env.HOOKS_DIR || `${DATA_ROOT}/hooks`),
color: '#a78bfa', // violet
mainFileName: 'HOOK.md',
fields: [
{
key: 'description',
label: 'Description',
type: 'text',
placeholder: 'Brief description of what this hook does',
},
{
key: 'events',
label: 'Events',
type: 'tags',
hint: 'Hook events this resource handles',
placeholder: 'Add event...',
},
],
},
```
**Nota**: Los hooks siempre son formato `folder` porque necesitan `hooks.json` + scripts opcionales. El campo `events` en frontmatter es informativo (para filtrado/busqueda); la configuracion real esta en `hooks.json`.
### Fase 2: Formato obligatorio carpeta
En `src/pages/[type]/new.astro` y `src/components/ResourceEditor.vue`:
- Para tipo `hooks`: forzar `format: 'folder'` sin mostrar toggle.
- Al crear un hook, generar automaticamente un `hooks.json` vacio (`{}`) como archivo inicial en el directorio.
En `src/lib/resources.ts``createResource`:
- Despues de crear la carpeta y el mainFile, si el tipo es `hooks`, escribir `hooks.json` con `{}`.
### Fase 3: API — lectura de hooks.json
El endpoint existente `/api/resources/[type]/[slug]/files/[...filePath].ts` ya permite GET de `hooks.json`.
Agregar un endpoint auxiliar o logica al GET de `/api/resources/[type]/[slug].ts` para incluir el contenido de `hooks.json` parseado en la respuesta JSON cuando el tipo es `hooks`:
```typescript
// En GET de [slug].ts, si type === 'hooks':
const hooksJsonBuf = await getResourceFile(type, slug, 'hooks.json');
if (hooksJsonBuf) {
resource.hooksConfig = JSON.parse(hooksJsonBuf.toString('utf8'));
}
```
### Fase 4: Pagina de detalle — preview de hooks.json
**`HookConfigPreview.astro`**: componente que recibe el JSON parseado de hooks.json y lo renderiza visualmente:
```
┌─────────────────────────────────────────┐
│ PostToolUse │
│ ┌─────────────────────────────────────┐ │
│ │ Matcher: Write | Edit │ │
│ │ ⚡ command │ │
│ │ └ ./scripts/lint.sh $CLAUDE_FILE... │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
- Cada evento como seccion con su nombre como titulo
- Cada matcher como sub-bloque
- Cada handler con icono segun tipo (command/prompt/agent)
- Truncar comandos largos con tooltip
En `[type]/[slug].astro`:
- Leer `hooks.json` del recurso
- Si existe, renderizar `HookConfigPreview` entre la descripcion y el arbol de archivos
- Si no existe, mostrar un aviso sutil
### Fase 5: Editor — HookConfigEditor.vue
Componente Vue interactivo para editar hooks.json visualmente:
**Estructura del formulario:**
1. **Selector de evento** — dropdown con los 7 eventos
2. **Lista de matchers** por evento — cada uno con:
- Campo `matcher` (texto, patron regex)
- Lista de handlers:
- Tipo: `command` | `prompt` | `agent`
- Valor: texto del comando, prompt, o nombre del agente
3. **Botones** — Agregar evento, agregar matcher, agregar handler, eliminar
**Integracion en ResourceEditor.vue:**
- Detectar tipo `hooks`
- Cargar `hooks.json` existente via API
- Renderizar `HookConfigEditor` debajo del editor de markdown
- Al guardar, enviar PUT al endpoint de files para actualizar `hooks.json`
**Alternativa simplificada (fase inicial):**
- Usar el campo `json` existente del registry para editar hooks.json como JSON crudo
- Agregar al registry.ts un campo:
```typescript
{
key: 'hooks-config',
label: 'Hook Configuration',
type: 'json',
placeholder: '{ "PostToolUse": [{ "matcher": "Write", "hooks": [{ "type": "command", "command": "echo done" }] }] }',
hint: 'JSON config that gets installed as hooks.json',
},
```
- **Pros**: Implementacion rapida, reutiliza componente existente
- **Contras**: UX menos amigable, propenso a errores de sintaxis
- **Recomendacion**: Empezar con JSON crudo, iterar al editor visual despues
### Fase 6: Scripts de instalacion — logica especial para hooks
Los hooks se instalan de forma diferente a skills/agents. No van a `.claude/hooks/` (ese directorio no existe en Claude Code). Se instalan asi:
1. **Scripts** se copian a `.claude/hooks/<slug>/scripts/`
2. **hooks.json** se lee y su contenido se **mergea** en `.claude/settings.local.json` bajo la clave `hooks`
3. **Los paths en commands** se reescriben para apuntar a la ubicacion de scripts
**Script de instalacion (bash):**
```bash
#!/usr/bin/env bash
set -euo pipefail
SLUG="my-hook"
HOOKS_DIR=".claude/hooks/$SLUG"
SETTINGS=".claude/settings.local.json"
# 1. Crear directorio y descargar archivos
mkdir -p "$HOOKS_DIR/scripts"
curl -fsSL "$ORIGIN/api/resources/hooks/$SLUG/files/hooks.json" -o "$HOOKS_DIR/hooks.json"
# ... descargar scripts ...
chmod +x "$HOOKS_DIR/scripts/"*
# 2. Mergear hooks.json en settings
if [ ! -f "$SETTINGS" ]; then
echo '{}' > "$SETTINGS"
fi
# Usar jq para mergear
jq --slurpfile new "$HOOKS_DIR/hooks.json" '
.hooks //= {} |
.hooks = (.hooks * $new[0])
' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS"
echo "✓ Installed hook $SLUG"
echo " Config merged into $SETTINGS"
echo " Scripts at $HOOKS_DIR/scripts/"
```
**Reescritura de paths**: Antes del merge, los paths en `command` que empiezan con `./scripts/` o `scripts/` se reescriben a `.claude/hooks/<slug>/scripts/`.
**Modificaciones en `i.ts` y `gi.ts`:**
- Detectar `type === 'hooks'`
- En vez del flujo generico de copiar a `.claude/<claudeDir>/<slug>`, usar el flujo especial:
1. Descargar hooks.json y scripts a `.claude/hooks/<slug>/`
2. Reescribir paths en hooks.json
3. Mergear en settings.local.json con jq
- Para `gi.ts`: lo mismo pero con `~/.claude/settings.json` (global)
**PowerShell**: Equivalente usando `ConvertFrom-Json`/`ConvertTo-Json` en vez de jq.
### Fase 7: Desinstalacion
Agregar endpoint o logica para generar script de desinstalacion:
**`src/pages/[type]/[slug]/uninstall.ts`** (nuevo, solo para hooks):
- Genera script que:
1. Lee settings.local.json
2. Elimina las entradas de hooks que coincidan con el slug
3. Elimina `.claude/hooks/<slug>/`
4. Escribe settings.local.json actualizado
Esto es mas importante para hooks que para otros tipos porque la instalacion modifica un archivo compartido (settings).
## Consideraciones especiales
### Diferencias con otros tipos de recursos
| Aspecto | Skills/Agents/Rules | Hooks |
|---------|-------------------|-------|
| Formato | file o folder | siempre folder |
| Destino instalacion | `.claude/<type>/<slug>.md` o carpeta | `.claude/hooks/<slug>/` + merge en settings |
| Desinstalacion | Borrar archivo/carpeta | Borrar carpeta + limpiar settings |
| Archivo config | No tiene | `hooks.json` |
| Claude Dir | `skills`, `agents`, etc. | `hooks` (para scripts, no para config) |
### Validacion de hooks.json
Validar estructura al guardar:
- Claves raiz deben ser eventos validos
- Cada entrada debe tener `matcher` (string) y `hooks` (array)
- Cada handler debe tener `type` (command/prompt/agent) y el valor correspondiente
### Seguridad
- Los scripts de hooks ejecutan comandos arbitrarios — la pagina de detalle debe mostrar un aviso claro
- En la instalacion, mostrar que comandos se van a registrar antes de ejecutar
- Nunca ejecutar hooks desde el servidor — solo generar scripts de instalacion
## Orden de implementacion
1. **Fase 1** — Registry: agregar tipo hooks (5 min)
2. **Fase 2** — Formato carpeta obligatorio + hooks.json inicial (15 min)
3. **Fase 3** — API lectura hooks.json (10 min)
4. **Fase 4** — Preview en pagina de detalle (30 min)
5. **Fase 5** — Editor (JSON crudo primero, visual despues) (20 min)
6. **Fase 6** — Scripts de instalacion especiales (45 min) — **mas complejo**
7. **Fase 7** — Script de desinstalacion (20 min)
## Verificacion
1. Crear un hook via web — verificar que se crea como carpeta con HOOK.md + hooks.json vacio
2. Editar hooks.json con configuracion real (PostToolUse + command)
3. Subir script a scripts/ via FileManager
4. Ver pagina de detalle — verificar preview de configuracion
5. Ejecutar `curl .../hooks/my-hook/i | bash` — verificar:
- Scripts descargados a `.claude/hooks/my-hook/scripts/`
- hooks.json mergeado en `.claude/settings.local.json`
- Paths reescritos correctamente
6. Verificar que el hook funciona ejecutando Claude Code en un proyecto con el hook instalado
7. Ejecutar script de desinstalacion — verificar limpieza de settings y archivos
8. Crear hook sin scripts (solo command inline) — verificar instalacion simplificada

257
PLAN.md
View File

@@ -1,179 +1,152 @@
# Skillit - Plan de Implementacion
# Plan: Soporte de Skills en formato carpeta (Folder-based resources)
## Contexto
App web para gestionar y distribuir Claude Code skills. Los usuarios suben/editan skills via web, y Claude Code las descarga ejecutando un script de sync (`curl ... | bash`).
**Stack**: Astro 5 (SSR) + Vue 3 (islands) + TailwindCSS 4 + Node adapter
**Deploy**: Coolify (Docker)
**Storage**: Filesystem directo (no Content Collections, no DB)
**Auth**: Ninguna
---
## Decision arquitectonica clave
**No usar Astro Content Collections.** Las Content Collections cachean datos en build time, pero esta app necesita CRUD en tiempo real. Usamos `gray-matter` + `fs/promises` directamente. Los skills se guardan en `data/skills/` (fuera de `src/content/`) para evitar conflictos con Astro.
---
## Estructura de archivos
El PDF oficial de Anthropic define que una skill puede ser una **carpeta** con subdirectorios:
```
skillit/
├── astro.config.mjs
├── tsconfig.json
├── package.json
── Dockerfile
├── .dockerignore
├── data/
│ └── skills/ # Skills .md (target del CRUD)
│ └── example-skill.md # Seed data
├── src/
│ ├── styles/global.css # @import "tailwindcss"
│ ├── lib/skills.ts # CRUD filesystem helpers
│ ├── components/
│ │ ├── SkillCard.astro # Card para el catalogo
│ │ ├── SkillEditor.vue # Editor markdown + preview
│ │ └── DeleteButton.vue # Boton eliminar con confirmacion
│ ├── layouts/Base.astro # HTML shell, nav, CSS
│ └── pages/
│ ├── index.astro # Catalogo (grid de cards)
│ ├── skills/
│ │ ├── [slug].astro # Ver skill (markdown renderizado)
│ │ ├── new.astro # Crear skill (monta SkillEditor)
│ │ └── [slug]/edit.astro # Editar skill
│ └── api/
│ ├── skills/index.ts # GET lista + POST crear
│ ├── skills/[slug].ts # GET raw + PUT + DELETE
│ └── sync.ts # GET -> script bash de sync
my-skill/
├── SKILL.md # Requerido
├── scripts/ # Opcional - codigo ejecutable
├── references/ # Opcional - documentacion
── assets/ # Opcional - plantillas, fuentes, iconos
```
---
Actualmente Skillit almacena cada recurso como un unico `.md` en `data/<type>/<slug>.md`. Queremos soportar **ambos formatos**: simple (archivo .md) y carpeta (directorio con SKILL.md + subdirectorios).
## Archivos a modificar
| Archivo | Cambio |
|---------|--------|
| `src/lib/registry.ts` | Agregar `mainFileName` por tipo y constante `FOLDER_SUBDIRS` |
| `src/lib/resources.ts` | Reescribir CRUD para detectar/manejar ambos formatos; nuevas funciones para sub-archivos |
| `src/lib/skills.ts` | Actualizar interfaz `Skill` con `format` y `files` |
| `src/lib/sync.ts` | Scripts de sync/push con soporte carpeta |
| `src/pages/api/resources/[type]/index.ts` | Aceptar `format` en POST |
| `src/pages/api/resources/[type]/[slug].ts` | Manejar carpeta en GET |
| `src/pages/[type]/[slug].astro` | Mostrar arbol de archivos |
| `src/pages/[type]/[slug]/edit.astro` | Pasar format/files al editor |
| `src/pages/[type]/new.astro` | Soporte selector de formato |
| `src/pages/[type]/[slug]/i.ts` | Install scripts multi-archivo |
| `src/pages/[type]/[slug]/gi.ts` | Install global multi-archivo |
| `src/components/ResourceEditor.vue` | Toggle formato + integrar FileManager |
## Archivos nuevos
| Archivo | Proposito |
|---------|-----------|
| `src/pages/api/resources/[type]/[slug]/files/index.ts` | Listar y subir sub-archivos |
| `src/pages/api/resources/[type]/[slug]/files/[...filePath].ts` | CRUD individual de sub-archivos |
| `src/components/FileManager.vue` | Componente Vue para gestionar sub-archivos |
| `src/components/FolderTree.astro` | Componente Astro para mostrar arbol de archivos en detalle |
## Fases de implementacion
### Fase 0: Scaffolding
### Fase 1: Registry (`registry.ts`)
1. **Crear proyecto Astro** en el directorio actual
```bash
npm create astro@latest . -- --template minimal --typescript strict --install --git
```
Agregar campo `mainFileName` a `ResourceTypeConfig`:
- skills: `SKILL.md`
- agents: `AGENT.md`
- output-styles: `OUTPUT-STYLE.md`
- rules: `RULE.md`
2. **Instalar dependencias**
```bash
npx astro add node vue tailwind
npm install gray-matter marked
```
Agregar constante `FOLDER_SUBDIRS = ['scripts', 'references', 'assets']`.
3. **Configurar `astro.config.mjs`**
- `output: 'server'` (SSR)
- `adapter: node({ mode: 'standalone' })`
- Integraciones: Vue, TailwindCSS vite plugin
### Fase 2: Core CRUD (`resources.ts`)
4. **`src/styles/global.css`**: solo `@import "tailwindcss"`
Cambios principales:
5. **Crear `data/skills/`** y el seed `example-skill.md`
1. **Nuevos tipos**:
- `ResourceFormat = 'file' | 'folder'`
- `ResourceFileEntry = { relativePath: string; size: number }`
- Agregar `format` y `files` a interfaz `Resource`
### Fase 1: Core library
2. **`resolveResource(type, slug)`** (nueva): detecta si el recurso es archivo o carpeta. Carpeta tiene prioridad si ambos existen.
6. **`src/lib/skills.ts`** - Modulo central de CRUD:
- `listSkills()` - lee directorio, parsea con gray-matter
- `getSkill(slug)` - lee un .md, devuelve null si no existe
- `createSkill(slug, content)` - escribe .md, error si ya existe
- `updateSkill(slug, content)` - sobreescribe .md
- `deleteSkill(slug)` - elimina .md
- `isValidSlug()` - valida `/^[a-z0-9][a-z0-9-]*[a-z0-9]$/`, max 64 chars
- `SKILLS_DIR` configurable via env var, default `data/skills/`
3. **`listResources(type)`**: escanear tanto `*.md` como directorios con el mainFileName.
### Fase 2: API endpoints
4. **`getResource(type, slug)`**: usar `resolveResource`, leer mainFile, listar sub-archivos si es carpeta.
7. **`src/pages/api/skills/index.ts`**
- GET: lista skills como JSON `[{slug, name, description, allowedTools}]`
- POST: crea skill, body `{slug, content}`, returns 201/400/409
5. **`createResource(type, slug, content, format?)`**: parametro `format` opcional (default `'file'`). Si `'folder'`, crear directorio y escribir mainFileName.
8. **`src/pages/api/skills/[slug].ts`**
- GET: devuelve raw .md (`Content-Type: text/markdown`)
- PUT: actualiza skill, body `{content}`, returns 200/404
- DELETE: elimina skill, returns 204/404
6. **`updateResource(type, slug, content)`**: auto-detectar formato, escribir en la ruta correcta.
9. **`src/pages/api/sync.ts`**
- GET: genera script bash que:
- Crea `~/.claude/skills/` si no existe
- Para cada skill: `mkdir -p` + `curl` del raw .md a `SKILL.md`
- Uso: `curl -fsSL https://skillit.example.com/api/sync | bash`
7. **`deleteResource(type, slug)`**: auto-detectar. Para carpeta usar `fs.rm(dirPath, { recursive: true })`.
### Fase 3: UI read-only
8. **Funciones nuevas para sub-archivos**:
- `listResourceFiles(type, slug)` - listar archivos auxiliares
- `getResourceFile(type, slug, relativePath)` - leer sub-archivo (Buffer)
- `addResourceFile(type, slug, relativePath, data: Buffer)` - escribir sub-archivo
- `deleteResourceFile(type, slug, relativePath)` - eliminar sub-archivo
- `convertToFolder(type, slug)` - convertir simple a carpeta
10. **`src/layouts/Base.astro`** - HTML shell con nav (logo + link "New Skill")
9. **Seguridad**: validar que `relativePath` no contenga `..`, solo permita rutas dentro de `FOLDER_SUBDIRS`.
11. **`src/components/SkillCard.astro`** - Card con nombre, descripcion truncada, badges de tools
### Fase 3: API de sub-archivos
12. **`src/pages/index.astro`** - Catalogo: llama `listSkills()`, renderiza grid de SkillCards. Empty state si no hay skills.
**`/api/resources/[type]/[slug]/files/index.ts`**:
- GET: lista sub-archivos (JSON)
- POST: subir archivo via multipart/form-data
13. **`src/pages/skills/[slug].astro`** - Vista detalle: renderiza markdown con `marked`, muestra metadata, botones Edit/Delete
**`/api/resources/[type]/[slug]/files/[...filePath].ts`**:
- GET: descargar sub-archivo
- PUT: subir/reemplazar sub-archivo
- DELETE: eliminar sub-archivo
### Fase 4: UI write
**Modificar `/api/resources/[type]/index.ts`**:
- POST acepta `format: 'file' | 'folder'` opcional
14. **`src/components/SkillEditor.vue`** (island `client:load`)
- Props: `initialContent?`, `slug?`, `mode: 'create' | 'edit'`
- Layout 2 paneles: textarea izquierda + preview derecha
- Campos de formulario arriba: name (auto-genera slug), description, allowed-tools
- Preview en tiempo real con `marked` (debounced 300ms)
- Save: POST o PUT segun mode, redirect al detalle
### Fase 4: Pagina de detalle (`[type]/[slug].astro`)
15. **`src/pages/skills/new.astro`** - Monta SkillEditor en modo create
- Comprobar `resource.format`
- Para carpeta: renderizar seccion "Files" con `FolderTree.astro`
- El raw markdown (para curl) sigue devolviendo solo el mainFile
16. **`src/pages/skills/[slug]/edit.astro`** - Carga skill, monta SkillEditor en modo edit con datos
**`FolderTree.astro`**: componente que muestra arbol de archivos con links de descarga.
17. **`src/components/DeleteButton.vue`** (island `client:load`)
- Prop: `slug`
- Click -> confirm -> `fetch DELETE` -> redirect a `/`
### Fase 5: Editor UI
### Fase 5: Deployment
**`ResourceEditor.vue`**:
- Nuevos props: `initialFormat`, `files`
- Toggle formato al crear (Simple / Carpeta)
- Seccion FileManager al editar recurso tipo carpeta
18. **Dockerfile** (multi-stage):
- Build: `node:22-alpine`, `npm install`, `npm run build`
- Runtime: copia `dist/` + `node_modules` (prod) + `data/skills/`
- `ENV SKILLS_DIR=/app/data/skills`
- `CMD ["node", "./dist/server/entry.mjs"]`
- Puerto 4321
**`FileManager.vue`** (nuevo componente Vue):
- Lista archivos con boton eliminar
- Boton subir: selector de subdirectorio (scripts/references/assets) + file input
- Llamadas fetch a la API de files
19. **.dockerignore**: `node_modules`, `dist`, `.git`, `.env`
### Fase 6: Scripts de instalacion
---
**`[type]/[slug]/i.ts` y `gi.ts`**:
- Para formato carpeta: generar script que crea estructura de directorios y descarga cada archivo
- URLs de sub-archivos: `${origin}/api/resources/${type}/${slug}/files/${relativePath}`
- `chmod +x` para archivos en `scripts/`
**`sync.ts`**:
- Sync: detectar formato por recurso, generar descarga multi-archivo
- Push: iterar tanto `*.md` como directorios; subir main content + sub-archivos
### Fase 7: Backward compat (`skills.ts`)
Actualizar interfaz `Skill` con `format` y `files`. El wrapper sigue delegando a `resources.ts`.
## Orden de implementacion
1. Fase 1 (registry) - base
2. Fase 2 (resources.ts CRUD) - critico
3. Fase 3 (API sub-archivos)
4. Fase 4 (pagina detalle)
5. Fase 5 (editor UI)
6. Fase 6 (scripts instalacion)
7. Fase 7 (backward compat)
## Verificacion
**Tras Fase 2 (API):**
```bash
npm run dev
curl http://localhost:4321/api/skills # JSON array
curl http://localhost:4321/api/skills/example-skill # raw .md
```
**Tras Fase 3 (UI read-only):**
- Visitar `/` -> ver card del example-skill
- Click card -> ver skill renderizada
**Tras Fase 4 (CRUD completo):**
- `/skills/new` -> crear skill -> aparece en catalogo
- Editar skill -> cambios persistidos
- Eliminar skill -> desaparece
- `curl http://localhost:4321/api/sync` -> script bash funcional
- `curl -fsSL http://localhost:4321/api/sync | bash && ls ~/.claude/skills/`
**Tras Fase 5 (Docker):**
```bash
docker build -t skillit .
docker run -p 4321:4321 -v skillit-data:/app/data/skills skillit
```
---
## Notas para Coolify
- Build pack: Dockerfile
- Volumen persistente: montar en `/app/data/skills` para que los skills sobrevivan rebuilds
- Puerto: 4321
- Env var opcional: `SKILLS_DIR` (default ya configurado en Dockerfile)
1. Crear un skill simple (`test-simple`) via web - verificar que funciona como antes
2. Crear un skill carpeta (`test-folder`) via web - verificar que se crea directorio con SKILL.md
3. Subir archivos a scripts/ y references/ del skill carpeta via FileManager
4. Ver pagina de detalle - verificar arbol de archivos
5. Ejecutar script de instalacion (`curl .../skills/test-folder/i | bash`) - verificar descarga completa
6. Verificar que listado muestra ambos formatos correctamente
7. Editar ambos formatos - verificar que se actualizan
8. Eliminar ambos formatos - verificar limpieza

View File

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

View File

@@ -0,0 +1,37 @@
---
name: Code Reviewer
description: An agent specialized in reviewing code for best practices, security issues, and performance.
author: Alejandro Martinez
author-email: amartinez2@certinia.com
tags: code-review, quality
tools: Read, Glob, Grep, WebSearch
model: claude-sonnet-4-5-20250929
permissionMode: plan
maxTurns: 10
skills: example-skill
---
# Code Reviewer Agent
You are a code review specialist. When asked to review code, follow these steps:
## Process
1. **Read** the files or changes to be reviewed
2. **Analyze** for:
- Security vulnerabilities (OWASP top 10)
- Performance issues
- Code style and consistency
- Error handling gaps
- Test coverage
3. **Report** findings organized by severity (critical, warning, suggestion)
## Output Format
For each finding:
- **File**: path and line number
- **Severity**: Critical / Warning / Suggestion
- **Issue**: Clear description
- **Fix**: Recommended solution
Always start with a summary of overall code quality before listing individual findings.

0
data/claude-md/.gitkeep Normal file
View File

View File

@@ -0,0 +1,19 @@
---
name: Concise Technical
description: Short, direct responses focused on code and technical accuracy. No fluff.
keep-coding-instructions: true
tags: concise, technical
author: Alejandro Martinez
author-email: amartinez2@certinia.com
---
# Concise Technical Style
Follow these formatting rules for all responses:
- **Be brief**: Get to the point immediately. No preambles like "Sure, I can help with that."
- **Code first**: When the answer is code, show the code before explaining it
- **No redundancy**: Don't repeat the question back. Don't summarize what you're about to do.
- **Bullet points**: Use bullets over paragraphs when listing multiple items
- **Technical precision**: Use exact terminology. Reference specific APIs, functions, and patterns by name.
- **Skip obvious context**: Don't explain basic concepts unless asked

View File

@@ -0,0 +1,38 @@
---
name: TypeScript Strict
description: Enforce strict TypeScript patterns and conventions for all TS/TSX files.
paths: src/**/*.ts, src/**/*.tsx
tags: typescript, conventions
author: Alejandro Martinez
author-email: amartinez2@certinia.com
---
# TypeScript Strict Rules
When working with TypeScript files, always follow these conventions:
## Type Safety
- Never use `any` — prefer `unknown` with type narrowing
- Always define return types for exported functions
- Use `readonly` for arrays and objects that shouldn't be mutated
- Prefer `interface` over `type` for object shapes (except unions/intersections)
## Imports
- Use named imports, not default imports
- Group imports: external libs, then internal modules, then relative paths
- No circular dependencies
## Naming
- `camelCase` for variables and functions
- `PascalCase` for types, interfaces, and classes
- `SCREAMING_SNAKE_CASE` for constants
- Prefix boolean variables with `is`, `has`, `should`, `can`
## Error Handling
- Never swallow errors silently (empty catch blocks)
- Use custom error classes for domain errors
- Always type error parameters in catch blocks

View File

@@ -0,0 +1,10 @@
---
name: testeo
description: Un test de skill
tags: test
allowed-tools: Bash, Read, Write, Edit
model: claude-opus-4-6
agent: Plan
---
# Es solo un prueba

View File

@@ -0,0 +1 @@
template

View File

@@ -0,0 +1 @@
referencia

View File

@@ -0,0 +1 @@
bash

View File

@@ -1,7 +1,22 @@
{
"example-skill": {
"downloads": 0,
"skills:example-skill": {
"downloads": 1,
"pushes": 1,
"lastPushedAt": "2026-02-12T13:27:13.727Z"
},
"agents:example-agent": {
"downloads": 1,
"pushes": 1,
"lastPushedAt": "2026-02-13T02:09:58.494Z"
},
"skills:example-skill-2": {
"downloads": 1,
"pushes": 1,
"lastPushedAt": "2026-02-13T09:59:01.660Z"
},
"skills:testeo": {
"downloads": 1,
"pushes": 1,
"lastPushedAt": "2026-02-13T12:18:38.376Z"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{c as d,a as o,d as u,l as b,g as p,t as f,w as C,v as _,b as m,T as S,F as T,h as E,i,m as M,o as c}from"./runtime-dom.esm-bundler.A7MyAQcw.js";import{_ as V}from"./_plugin-vue_export-helper.DlAUqK2U.js";const B=E({__name:"EditGate",props:{slug:{},authorEmail:{},authorName:{},authorHasToken:{type:Boolean}},setup(h,{expose:e}){e();const r=h,t=i(!1),s=i(""),a=i(""),n=i(!1),v=i();async function x(){if(!r.authorEmail||!r.authorHasToken){window.location.href=`/${r.slug}/edit`;return}const l=localStorage.getItem("skillshere-token")||"";if(l)try{if((await fetch("/api/auth/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:r.authorEmail,token:l})})).ok){localStorage.setItem("skillshere-token",l),window.location.href=`/${r.slug}/edit`;return}}catch{}t.value=!0,a.value="",s.value="",M(()=>v.value?.focus())}async function g(){n.value=!0,a.value="";try{const l=await fetch("/api/auth/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:r.authorEmail,token:s.value})});if(!l.ok){const y=await l.json();a.value=y.error||"Invalid token";return}localStorage.setItem("skillshere-token",s.value),window.location.href=`/${r.slug}/edit`}catch{a.value="Could not verify token"}finally{n.value=!1}}function w(){t.value=!1,window.location.href=`/new?from=${encodeURIComponent(r.slug)}`}const k={props:r,showModal:t,token:s,error:a,verifying:n,tokenInput:v,handleClick:x,verify:g,forkSkill:w};return Object.defineProperty(k,"__isScriptSetup",{enumerable:!1,value:!0}),k}}),I={class:"w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl"},N={class:"text-sm text-gray-500 mb-4"},j={class:"text-gray-300"},O={key:0,class:"mt-2 text-sm text-red-400"},A={class:"mt-4 flex items-center gap-3"},H=["disabled"],P={key:0,class:"h-4 w-4 animate-spin",fill:"none",viewBox:"0 0 24 24"};function F(h,e,r,t,s,a){return c(),d(T,null,[o("button",{onClick:t.handleClick,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"},[...e[3]||(e[3]=[o("svg",{class:"h-3.5 w-3.5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor","stroke-width":"2"},[o("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"})],-1),u(" Edit ",-1)])]),(c(),b(S,{to:"body"},[t.showModal?(c(),d("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",onClick:e[2]||(e[2]=p(n=>t.showModal=!1,["self"]))},[o("div",I,[e[7]||(e[7]=o("h3",{class:"text-lg font-semibold text-white mb-1"},"Author Verification",-1)),o("p",N,[e[4]||(e[4]=u(" This skill is owned by ",-1)),o("strong",j,f(r.authorName||r.authorEmail),1),e[5]||(e[5]=u(". Enter your token to edit. ",-1))]),o("form",{onSubmit:p(t.verify,["prevent"])},[C(o("input",{ref:"tokenInput","onUpdate:modelValue":e[0]||(e[0]=n=>t.token=n),type:"password",placeholder:"Paste your author token...",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 font-mono focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"},null,512),[[_,t.token]]),t.error?(c(),d("p",O,f(t.error),1)):m("",!0),o("div",A,[o("button",{type:"submit",disabled:t.verifying||!t.token,class:"inline-flex items-center gap-2 rounded-xl bg-[var(--color-accent-500)] px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-[var(--color-accent-500)]/20 hover:bg-[var(--color-accent-600)] disabled:opacity-50 active:scale-[0.97] transition-all"},[t.verifying?(c(),d("svg",P,[...e[6]||(e[6]=[o("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),o("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):m("",!0),u(" "+f(t.verifying?"Verifying...":"Continue to Edit"),1)],8,H),o("button",{type:"button",onClick:t.forkSkill,class:"text-sm text-[var(--color-accent-400)] hover:text-[var(--color-accent-300)] transition-colors"}," Fork instead "),o("button",{type:"button",onClick:e[1]||(e[1]=n=>t.showModal=!1),class:"ml-auto text-sm text-gray-600 hover:text-gray-300 transition-colors"}," Cancel ")])],32)])])):m("",!0)]))],64)}const D=V(B,[["render",F]]);export{D as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
const s=(t,r)=>{const o=t.__vccOpts||t;for(const[c,e]of r)o[c]=e;return o};export{s as _};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{h as m,s as r,u as y,x as v,S}from"./runtime-dom.esm-bundler.A7MyAQcw.js";const g=()=>{},h=m({props:{value:String,name:String,hydrate:{type:Boolean,default:!0}},setup({name:t,value:s,hydrate:o}){if(!s)return()=>null;let c=o?"astro-slot":"astro-static-slot";return()=>r(c,{name:t,innerHTML:s})}});var A=h;let p=new WeakMap;var H=t=>async(s,o,c,{client:l})=>{if(!t.hasAttribute("ssr"))return;const f=s.name?`${s.name} Host`:void 0,u={};for(const[n,a]of Object.entries(c))u[n]=()=>r(A,{value:a,name:n==="default"?void 0:n});const i=l!=="only",d=i?y:v;let e=p.get(t);if(e)e.props=o,e.slots=u,e.component.$forceUpdate();else{e={props:o,slots:u};const n=d({name:f,render(){let a=r(s,e.props,e.slots);return e.component=this,b(s.setup)&&(a=r(S,null,a)),a}});n.config.idPrefix=t.getAttribute("prefix")??void 0,await g(),n.mount(t,i),p.set(t,e),t.addEventListener("astro:unmount",()=>n.unmount(),{once:!0})}};function b(t){const s=t?.constructor;return s&&s.name==="AsyncFunction"}export{H as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
export { c as createExports, a as start } from './chunks/_@astrojs-ssr-adapter_BeL8VyJ8.mjs';

View File

@@ -1,3 +0,0 @@
const onRequest = (_, next) => next();
export { onRequest };

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
import { e as createAstro, f as createComponent, n as renderHead, o as renderSlot, r as renderTemplate } from './astro/server_CF97kUu8.mjs';
import 'piccolore';
import 'clsx';
/* empty css */
const $$Astro = createAstro("https://skills.here.run.place");
const $$Base = createComponent(($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$Base;
const { title = "Skills Here" } = Astro2.props;
return renderTemplate`<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"><link rel="icon" type="image/svg+xml" href="/favicon.svg"><title>${title}</title>${renderHead()}</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-6xl flex items-center justify-between px-6 py-4"> <a href="/" class="group flex items-center gap-2.5"> <img src="/favicon.svg" alt="Skills Here" class="h-8 w-8"> <span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">Skills Here</span> </a> <a href="/new" class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 hover:shadow-accent-500/30 active:scale-[0.97] transition-all"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path> </svg>
New Skill
</a> </div> </nav> <main class="relative mx-auto max-w-6xl px-6 py-10"> ${renderSlot($$result, $$slots["default"])} </main> </body></html>`;
}, "/Users/alex/projects/skillit/src/layouts/Base.astro", void 0);
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export { $$Base as $, _export_sfc as _ };

View File

@@ -1,364 +0,0 @@
import { al as NOOP_MIDDLEWARE_HEADER, am as REDIRECT_STATUS_CODES, A as AstroError, an as ActionsReturnedInvalidDataError, T as DEFAULT_404_COMPONENT } from './astro/server_CF97kUu8.mjs';
import { parse, stringify } from 'devalue';
import { escape } from 'html-escaper';
const NOOP_MIDDLEWARE_FN = async (_ctx, next) => {
const response = await next();
response.headers.set(NOOP_MIDDLEWARE_HEADER, "true");
return response;
};
const ACTION_QUERY_PARAMS$1 = {
actionName: "_action"};
const ACTION_RPC_ROUTE_PATTERN = "/_actions/[...path]";
const __vite_import_meta_env__ = {"ASSETS_PREFIX": undefined, "BASE_URL": "/", "DEV": false, "MODE": "production", "PROD": true, "SITE": "https://skills.here.run.place", "SSR": true};
const ACTION_QUERY_PARAMS = ACTION_QUERY_PARAMS$1;
const codeToStatusMap = {
// Implemented from IANA HTTP Status Code Registry
// https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
NOT_ACCEPTABLE: 406,
PROXY_AUTHENTICATION_REQUIRED: 407,
REQUEST_TIMEOUT: 408,
CONFLICT: 409,
GONE: 410,
LENGTH_REQUIRED: 411,
PRECONDITION_FAILED: 412,
CONTENT_TOO_LARGE: 413,
URI_TOO_LONG: 414,
UNSUPPORTED_MEDIA_TYPE: 415,
RANGE_NOT_SATISFIABLE: 416,
EXPECTATION_FAILED: 417,
MISDIRECTED_REQUEST: 421,
UNPROCESSABLE_CONTENT: 422,
LOCKED: 423,
FAILED_DEPENDENCY: 424,
TOO_EARLY: 425,
UPGRADE_REQUIRED: 426,
PRECONDITION_REQUIRED: 428,
TOO_MANY_REQUESTS: 429,
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
UNAVAILABLE_FOR_LEGAL_REASONS: 451,
INTERNAL_SERVER_ERROR: 500,
NOT_IMPLEMENTED: 501,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
HTTP_VERSION_NOT_SUPPORTED: 505,
VARIANT_ALSO_NEGOTIATES: 506,
INSUFFICIENT_STORAGE: 507,
LOOP_DETECTED: 508,
NETWORK_AUTHENTICATION_REQUIRED: 511
};
const statusToCodeMap = Object.entries(codeToStatusMap).reduce(
// reverse the key-value pairs
(acc, [key, value]) => ({ ...acc, [value]: key }),
{}
);
class ActionError extends Error {
type = "AstroActionError";
code = "INTERNAL_SERVER_ERROR";
status = 500;
constructor(params) {
super(params.message);
this.code = params.code;
this.status = ActionError.codeToStatus(params.code);
if (params.stack) {
this.stack = params.stack;
}
}
static codeToStatus(code) {
return codeToStatusMap[code];
}
static statusToCode(status) {
return statusToCodeMap[status] ?? "INTERNAL_SERVER_ERROR";
}
static fromJson(body) {
if (isInputError(body)) {
return new ActionInputError(body.issues);
}
if (isActionError(body)) {
return new ActionError(body);
}
return new ActionError({
code: "INTERNAL_SERVER_ERROR"
});
}
}
function isActionError(error) {
return typeof error === "object" && error != null && "type" in error && error.type === "AstroActionError";
}
function isInputError(error) {
return typeof error === "object" && error != null && "type" in error && error.type === "AstroActionInputError" && "issues" in error && Array.isArray(error.issues);
}
class ActionInputError extends ActionError {
type = "AstroActionInputError";
// We don't expose all ZodError properties.
// Not all properties will serialize from server to client,
// and we don't want to import the full ZodError object into the client.
issues;
fields;
constructor(issues) {
super({
message: `Failed to validate: ${JSON.stringify(issues, null, 2)}`,
code: "BAD_REQUEST"
});
this.issues = issues;
this.fields = {};
for (const issue of issues) {
if (issue.path.length > 0) {
const key = issue.path[0].toString();
this.fields[key] ??= [];
this.fields[key]?.push(issue.message);
}
}
}
}
function getActionQueryString(name) {
const searchParams = new URLSearchParams({ [ACTION_QUERY_PARAMS$1.actionName]: name });
return `?${searchParams.toString()}`;
}
function serializeActionResult(res) {
if (res.error) {
if (Object.assign(__vite_import_meta_env__, { _: process.env._ })?.DEV) {
actionResultErrorStack.set(res.error.stack);
}
let body2;
if (res.error instanceof ActionInputError) {
body2 = {
type: res.error.type,
issues: res.error.issues,
fields: res.error.fields
};
} else {
body2 = {
...res.error,
message: res.error.message
};
}
return {
type: "error",
status: res.error.status,
contentType: "application/json",
body: JSON.stringify(body2)
};
}
if (res.data === void 0) {
return {
type: "empty",
status: 204
};
}
let body;
try {
body = stringify(res.data, {
// Add support for URL objects
URL: (value) => value instanceof URL && value.href
});
} catch (e) {
let hint = ActionsReturnedInvalidDataError.hint;
if (res.data instanceof Response) {
hint = REDIRECT_STATUS_CODES.includes(res.data.status) ? "If you need to redirect when the action succeeds, trigger a redirect where the action is called. See the Actions guide for server and client redirect examples: https://docs.astro.build/en/guides/actions." : "If you need to return a Response object, try using a server endpoint instead. See https://docs.astro.build/en/guides/endpoints/#server-endpoints-api-routes";
}
throw new AstroError({
...ActionsReturnedInvalidDataError,
message: ActionsReturnedInvalidDataError.message(String(e)),
hint
});
}
return {
type: "data",
status: 200,
contentType: "application/json+devalue",
body
};
}
function deserializeActionResult(res) {
if (res.type === "error") {
let json;
try {
json = JSON.parse(res.body);
} catch {
return {
data: void 0,
error: new ActionError({
message: res.body,
code: "INTERNAL_SERVER_ERROR"
})
};
}
if (Object.assign(__vite_import_meta_env__, { _: process.env._ })?.PROD) {
return { error: ActionError.fromJson(json), data: void 0 };
} else {
const error = ActionError.fromJson(json);
error.stack = actionResultErrorStack.get();
return {
error,
data: void 0
};
}
}
if (res.type === "empty") {
return { data: void 0, error: void 0 };
}
return {
data: parse(res.body, {
URL: (href) => new URL(href)
}),
error: void 0
};
}
const actionResultErrorStack = /* @__PURE__ */ (function actionResultErrorStackFn() {
let errorStack;
return {
set(stack) {
errorStack = stack;
},
get() {
return errorStack;
}
};
})();
function template({
title,
pathname,
statusCode = 404,
tabTitle,
body
}) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${tabTitle}</title>
<style>
:root {
--gray-10: hsl(258, 7%, 10%);
--gray-20: hsl(258, 7%, 20%);
--gray-30: hsl(258, 7%, 30%);
--gray-40: hsl(258, 7%, 40%);
--gray-50: hsl(258, 7%, 50%);
--gray-60: hsl(258, 7%, 60%);
--gray-70: hsl(258, 7%, 70%);
--gray-80: hsl(258, 7%, 80%);
--gray-90: hsl(258, 7%, 90%);
--black: #13151A;
--accent-light: #E0CCFA;
}
* {
box-sizing: border-box;
}
html {
background: var(--black);
color-scheme: dark;
accent-color: var(--accent-light);
}
body {
background-color: var(--gray-10);
color: var(--gray-80);
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
line-height: 1.5;
margin: 0;
}
a {
color: var(--accent-light);
}
.center {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
}
h1 {
margin-bottom: 8px;
color: white;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-weight: 700;
margin-top: 1rem;
margin-bottom: 0;
}
.statusCode {
color: var(--accent-light);
}
.astro-icon {
height: 124px;
width: 124px;
}
pre, code {
padding: 2px 8px;
background: rgba(0,0,0, 0.25);
border: 1px solid rgba(255,255,255, 0.25);
border-radius: 4px;
font-size: 1.2em;
margin-top: 0;
max-width: 60em;
}
</style>
</head>
<body>
<main class="center">
<svg class="astro-icon" xmlns="http://www.w3.org/2000/svg" width="64" height="80" viewBox="0 0 64 80" fill="none"> <path d="M20.5253 67.6322C16.9291 64.3531 15.8793 57.4632 17.3776 52.4717C19.9755 55.6188 23.575 56.6157 27.3035 57.1784C33.0594 58.0468 38.7122 57.722 44.0592 55.0977C44.6709 54.7972 45.2362 54.3978 45.9045 53.9931C46.4062 55.4451 46.5368 56.9109 46.3616 58.4028C45.9355 62.0362 44.1228 64.8429 41.2397 66.9705C40.0868 67.8215 38.8669 68.5822 37.6762 69.3846C34.0181 71.8508 33.0285 74.7426 34.403 78.9491C34.4357 79.0516 34.4649 79.1541 34.5388 79.4042C32.6711 78.5705 31.3069 77.3565 30.2674 75.7604C29.1694 74.0757 28.6471 72.2121 28.6196 70.1957C28.6059 69.2144 28.6059 68.2244 28.4736 67.257C28.1506 64.8985 27.0406 63.8425 24.9496 63.7817C22.8036 63.7192 21.106 65.0426 20.6559 67.1268C20.6215 67.2865 20.5717 67.4446 20.5218 67.6304L20.5253 67.6322Z" fill="white"/> <path d="M20.5253 67.6322C16.9291 64.3531 15.8793 57.4632 17.3776 52.4717C19.9755 55.6188 23.575 56.6157 27.3035 57.1784C33.0594 58.0468 38.7122 57.722 44.0592 55.0977C44.6709 54.7972 45.2362 54.3978 45.9045 53.9931C46.4062 55.4451 46.5368 56.9109 46.3616 58.4028C45.9355 62.0362 44.1228 64.8429 41.2397 66.9705C40.0868 67.8215 38.8669 68.5822 37.6762 69.3846C34.0181 71.8508 33.0285 74.7426 34.403 78.9491C34.4357 79.0516 34.4649 79.1541 34.5388 79.4042C32.6711 78.5705 31.3069 77.3565 30.2674 75.7604C29.1694 74.0757 28.6471 72.2121 28.6196 70.1957C28.6059 69.2144 28.6059 68.2244 28.4736 67.257C28.1506 64.8985 27.0406 63.8425 24.9496 63.7817C22.8036 63.7192 21.106 65.0426 20.6559 67.1268C20.6215 67.2865 20.5717 67.4446 20.5218 67.6304L20.5253 67.6322Z" fill="url(#paint0_linear_738_686)"/> <path d="M0 51.6401C0 51.6401 10.6488 46.4654 21.3274 46.4654L29.3786 21.6102C29.6801 20.4082 30.5602 19.5913 31.5538 19.5913C32.5474 19.5913 33.4275 20.4082 33.7289 21.6102L41.7802 46.4654C54.4274 46.4654 63.1076 51.6401 63.1076 51.6401C63.1076 51.6401 45.0197 2.48776 44.9843 2.38914C44.4652 0.935933 43.5888 0 42.4073 0H20.7022C19.5206 0 18.6796 0.935933 18.1251 2.38914C18.086 2.4859 0 51.6401 0 51.6401Z" fill="white"/> <defs> <linearGradient id="paint0_linear_738_686" x1="31.554" y1="75.4423" x2="39.7462" y2="48.376" gradientUnits="userSpaceOnUse"> <stop stop-color="#D83333"/> <stop offset="1" stop-color="#F041FF"/> </linearGradient> </defs> </svg>
<h1>${statusCode ? `<span class="statusCode">${statusCode}: </span> ` : ""}<span class="statusMessage">${title}</span></h1>
${body || `
<pre>Path: ${escape(pathname)}</pre>
`}
</main>
</body>
</html>`;
}
const DEFAULT_404_ROUTE = {
component: DEFAULT_404_COMPONENT,
generate: () => "",
params: [],
pattern: /^\/404\/?$/,
prerender: false,
pathname: "/404",
segments: [[{ content: "404", dynamic: false, spread: false }]],
type: "page",
route: "/404",
fallbackRoutes: [],
isIndex: false,
origin: "internal"
};
function ensure404Route(manifest) {
if (!manifest.routes.some((route) => route.route === "/404")) {
manifest.routes.push(DEFAULT_404_ROUTE);
}
return manifest;
}
async function default404Page({ pathname }) {
return new Response(
template({
statusCode: 404,
title: "Not found",
tabTitle: "404: Not Found",
pathname
}),
{ status: 404, headers: { "Content-Type": "text/html" } }
);
}
default404Page.isAstroComponentFactory = true;
const default404Instance = {
default: default404Page
};
export { ActionError as A, DEFAULT_404_ROUTE as D, NOOP_MIDDLEWARE_FN as N, ACTION_RPC_ROUTE_PATTERN as a, ACTION_QUERY_PARAMS as b, default404Instance as c, deserializeActionResult as d, ensure404Route as e, getActionQueryString as g, serializeActionResult as s };

File diff suppressed because it is too large Load Diff

View File

@@ -1,157 +0,0 @@
import { promises, existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
function defineDriver(factory) {
return factory;
}
function createError(driver, message, opts) {
const err = new Error(`[unstorage] [${driver}] ${message}`, opts);
if (Error.captureStackTrace) {
Error.captureStackTrace(err, createError);
}
return err;
}
function createRequiredError(driver, name) {
if (Array.isArray(name)) {
return createError(
driver,
`Missing some of the required options ${name.map((n) => "`" + n + "`").join(", ")}`
);
}
return createError(driver, `Missing required option \`${name}\`.`);
}
function ignoreNotfound(err) {
return err.code === "ENOENT" || err.code === "EISDIR" ? null : err;
}
function ignoreExists(err) {
return err.code === "EEXIST" ? null : err;
}
async function writeFile(path, data, encoding) {
await ensuredir(dirname(path));
return promises.writeFile(path, data, encoding);
}
function readFile(path, encoding) {
return promises.readFile(path, encoding).catch(ignoreNotfound);
}
function unlink(path) {
return promises.unlink(path).catch(ignoreNotfound);
}
function readdir(dir) {
return promises.readdir(dir, { withFileTypes: true }).catch(ignoreNotfound).then((r) => r || []);
}
async function ensuredir(dir) {
if (existsSync(dir)) {
return;
}
await ensuredir(dirname(dir)).catch(ignoreExists);
await promises.mkdir(dir).catch(ignoreExists);
}
async function readdirRecursive(dir, ignore, maxDepth) {
if (ignore && ignore(dir)) {
return [];
}
const entries = await readdir(dir);
const files = [];
await Promise.all(
entries.map(async (entry) => {
const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (maxDepth === void 0 || maxDepth > 0) {
const dirFiles = await readdirRecursive(
entryPath,
ignore,
maxDepth === void 0 ? void 0 : maxDepth - 1
);
files.push(...dirFiles.map((f) => entry.name + "/" + f));
}
} else {
if (!(ignore && ignore(entry.name))) {
files.push(entry.name);
}
}
})
);
return files;
}
async function rmRecursive(dir) {
const entries = await readdir(dir);
await Promise.all(
entries.map((entry) => {
const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
return rmRecursive(entryPath).then(() => promises.rmdir(entryPath));
} else {
return promises.unlink(entryPath);
}
})
);
}
const PATH_TRAVERSE_RE = /\.\.:|\.\.$/;
const DRIVER_NAME = "fs-lite";
const fsLite = defineDriver((opts = {}) => {
if (!opts.base) {
throw createRequiredError(DRIVER_NAME, "base");
}
opts.base = resolve(opts.base);
const r = (key) => {
if (PATH_TRAVERSE_RE.test(key)) {
throw createError(
DRIVER_NAME,
`Invalid key: ${JSON.stringify(key)}. It should not contain .. segments`
);
}
const resolved = join(opts.base, key.replace(/:/g, "/"));
return resolved;
};
return {
name: DRIVER_NAME,
options: opts,
flags: {
maxDepth: true
},
hasItem(key) {
return existsSync(r(key));
},
getItem(key) {
return readFile(r(key), "utf8");
},
getItemRaw(key) {
return readFile(r(key));
},
async getMeta(key) {
const { atime, mtime, size, birthtime, ctime } = await promises.stat(r(key)).catch(() => ({}));
return { atime, mtime, size, birthtime, ctime };
},
setItem(key, value) {
if (opts.readOnly) {
return;
}
return writeFile(r(key), value, "utf8");
},
setItemRaw(key, value) {
if (opts.readOnly) {
return;
}
return writeFile(r(key), value);
},
removeItem(key) {
if (opts.readOnly) {
return;
}
return unlink(r(key));
},
getKeys(_base, topts) {
return readdirRecursive(r("."), opts.ignore, topts?.maxDepth);
},
async clear() {
if (opts.readOnly || opts.noClear) {
return;
}
await rmRecursive(r("."));
}
};
});
export { fsLite as default };

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,188 +0,0 @@
function appendForwardSlash(path) {
return path.endsWith("/") ? path : path + "/";
}
function prependForwardSlash(path) {
return path[0] === "/" ? path : "/" + path;
}
const MANY_TRAILING_SLASHES = /\/{2,}$/g;
function collapseDuplicateTrailingSlashes(path, trailingSlash) {
if (!path) {
return path;
}
return path.replace(MANY_TRAILING_SLASHES, trailingSlash ? "/" : "") || "/";
}
function removeTrailingForwardSlash(path) {
return path.endsWith("/") ? path.slice(0, path.length - 1) : path;
}
function removeLeadingForwardSlash(path) {
return path.startsWith("/") ? path.substring(1) : path;
}
function trimSlashes(path) {
return path.replace(/^\/|\/$/g, "");
}
function isString(path) {
return typeof path === "string" || path instanceof String;
}
const INTERNAL_PREFIXES = /* @__PURE__ */ new Set(["/_", "/@", "/.", "//"]);
const JUST_SLASHES = /^\/{2,}$/;
function isInternalPath(path) {
return INTERNAL_PREFIXES.has(path.slice(0, 2)) && !JUST_SLASHES.test(path);
}
function joinPaths(...paths) {
return paths.filter(isString).map((path, i) => {
if (i === 0) {
return removeTrailingForwardSlash(path);
} else if (i === paths.length - 1) {
return removeLeadingForwardSlash(path);
} else {
return trimSlashes(path);
}
}).join("/");
}
function removeQueryString(path) {
const index = path.lastIndexOf("?");
return index > 0 ? path.substring(0, index) : path;
}
function isRemotePath(src) {
if (!src) return false;
const trimmed = src.trim();
if (!trimmed) return false;
let decoded = trimmed;
let previousDecoded = "";
let maxIterations = 10;
while (decoded !== previousDecoded && maxIterations > 0) {
previousDecoded = decoded;
try {
decoded = decodeURIComponent(decoded);
} catch {
break;
}
maxIterations--;
}
if (/^[a-zA-Z]:/.test(decoded)) {
return false;
}
if (decoded[0] === "/" && decoded[1] !== "/" && decoded[1] !== "\\") {
return false;
}
if (decoded[0] === "\\") {
return true;
}
if (decoded.startsWith("//")) {
return true;
}
try {
const url = new URL(decoded, "http://n");
if (url.username || url.password) {
return true;
}
if (decoded.includes("@") && !url.pathname.includes("@") && !url.search.includes("@")) {
return true;
}
if (url.origin !== "http://n") {
const protocol = url.protocol.toLowerCase();
if (protocol === "file:") {
return false;
}
return true;
}
if (URL.canParse(decoded)) {
return true;
}
return false;
} catch {
return true;
}
}
function isParentDirectory(parentPath, childPath) {
if (!parentPath || !childPath) {
return false;
}
if (parentPath.includes("://") || childPath.includes("://")) {
return false;
}
if (isRemotePath(parentPath) || isRemotePath(childPath)) {
return false;
}
if (parentPath.includes("..") || childPath.includes("..")) {
return false;
}
if (parentPath.includes("\0") || childPath.includes("\0")) {
return false;
}
const normalizedParent = appendForwardSlash(slash(parentPath).toLowerCase());
const normalizedChild = slash(childPath).toLowerCase();
if (normalizedParent === normalizedChild || normalizedParent === normalizedChild + "/") {
return false;
}
return normalizedChild.startsWith(normalizedParent);
}
function slash(path) {
return path.replace(/\\/g, "/");
}
function fileExtension(path) {
const ext = path.split(".").pop();
return ext !== path ? `.${ext}` : "";
}
const WITH_FILE_EXT = /\/[^/]+\.\w+$/;
function hasFileExtension(path) {
return WITH_FILE_EXT.test(path);
}
function matchPattern(url, remotePattern) {
return matchProtocol(url, remotePattern.protocol) && matchHostname(url, remotePattern.hostname, true) && matchPort(url, remotePattern.port) && matchPathname(url, remotePattern.pathname, true);
}
function matchPort(url, port) {
return !port || port === url.port;
}
function matchProtocol(url, protocol) {
return !protocol || protocol === url.protocol.slice(0, -1);
}
function matchHostname(url, hostname, allowWildcard = false) {
if (!hostname) {
return true;
} else if (!allowWildcard || !hostname.startsWith("*")) {
return hostname === url.hostname;
} else if (hostname.startsWith("**.")) {
const slicedHostname = hostname.slice(2);
return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname);
} else if (hostname.startsWith("*.")) {
const slicedHostname = hostname.slice(1);
if (!url.hostname.endsWith(slicedHostname)) {
return false;
}
const subdomainWithDot = url.hostname.slice(0, -(slicedHostname.length - 1));
return subdomainWithDot.endsWith(".") && !subdomainWithDot.slice(0, -1).includes(".");
}
return false;
}
function matchPathname(url, pathname, allowWildcard = false) {
if (!pathname) {
return true;
} else if (!allowWildcard || !pathname.endsWith("*")) {
return pathname === url.pathname;
} else if (pathname.endsWith("/**")) {
const slicedPathname = pathname.slice(0, -2);
return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname);
} else if (pathname.endsWith("/*")) {
const slicedPathname = pathname.slice(0, -1);
const additionalPathChunks = url.pathname.replace(slicedPathname, "").split("/").filter(Boolean);
return additionalPathChunks.length === 1;
}
return false;
}
function isRemoteAllowed(src, {
domains,
remotePatterns
}) {
if (!URL.canParse(src)) {
return false;
}
const url = new URL(src);
if (!["http:", "https:", "data:"].includes(url.protocol)) {
return false;
}
return domains.some((domain) => matchHostname(url, domain)) || remotePatterns.some((remotePattern) => matchPattern(url, remotePattern));
}
export { isRemotePath as a, isParentDirectory as b, appendForwardSlash as c, removeTrailingForwardSlash as d, isInternalPath as e, fileExtension as f, collapseDuplicateTrailingSlashes as g, hasFileExtension as h, isRemoteAllowed as i, joinPaths as j, matchPattern as m, prependForwardSlash as p, removeQueryString as r, slash as s, trimSlashes as t };

View File

@@ -1,101 +0,0 @@
import { A as AstroError, ao as MissingSharp } from './astro/server_CF97kUu8.mjs';
import { b as baseService, p as parseQuality } from './node_HH9e2ntY.mjs';
let sharp;
const qualityTable = {
low: 25,
mid: 50,
high: 80,
max: 100
};
async function loadSharp() {
let sharpImport;
try {
sharpImport = (await import('sharp')).default;
} catch {
throw new AstroError(MissingSharp);
}
sharpImport.cache(false);
return sharpImport;
}
const fitMap = {
fill: "fill",
contain: "inside",
cover: "cover",
none: "outside",
"scale-down": "inside",
outside: "outside",
inside: "inside"
};
const sharpService = {
validateOptions: baseService.validateOptions,
getURL: baseService.getURL,
parseURL: baseService.parseURL,
getHTMLAttributes: baseService.getHTMLAttributes,
getSrcSet: baseService.getSrcSet,
async transform(inputBuffer, transformOptions, config) {
if (!sharp) sharp = await loadSharp();
const transform = transformOptions;
const kernel = config.service.config.kernel;
if (transform.format === "svg") return { data: inputBuffer, format: "svg" };
const result = sharp(inputBuffer, {
failOnError: false,
pages: -1,
limitInputPixels: config.service.config.limitInputPixels
});
result.rotate();
const { format } = await result.metadata();
const withoutEnlargement = Boolean(transform.fit);
if (transform.width && transform.height && transform.fit) {
const fit = fitMap[transform.fit] ?? "inside";
result.resize({
width: Math.round(transform.width),
height: Math.round(transform.height),
kernel,
fit,
position: transform.position,
withoutEnlargement
});
} else if (transform.height && !transform.width) {
result.resize({
height: Math.round(transform.height),
kernel,
withoutEnlargement
});
} else if (transform.width) {
result.resize({
width: Math.round(transform.width),
kernel,
withoutEnlargement
});
}
if (transform.background) {
result.flatten({ background: transform.background });
}
if (transform.format) {
let quality = void 0;
if (transform.quality) {
const parsedQuality = parseQuality(transform.quality);
if (typeof parsedQuality === "number") {
quality = parsedQuality;
} else {
quality = transform.quality in qualityTable ? qualityTable[transform.quality] : void 0;
}
}
if (transform.format === "webp" && format === "gif") {
result.webp({ quality: typeof quality === "number" ? quality : void 0, loop: 0 });
} else {
result.toFormat(transform.format, { quality });
}
}
const { data, info } = await result.toBuffer({ resolveWithObject: true });
const needsCopy = "buffer" in data && data.buffer instanceof SharedArrayBuffer;
return {
data: needsCopy ? new Uint8Array(data) : data,
format: info.format
};
}
};
var sharp_default = sharpService;
export { sharp_default as default };

View File

@@ -1,109 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
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;
function isValidSlug(slug) {
return slug.length >= 2 && slug.length <= MAX_SLUG_LENGTH && SLUG_RE.test(slug);
}
function skillPath(slug) {
return path.join(SKILLS_DIR, `${slug}.md`);
}
function parseTools(val) {
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, raw) {
const { data, content } = matter(raw);
return {
slug,
name: data.name || slug,
description: data.description || "",
"allowed-tools": parseTools(data["allowed-tools"] ?? data.allowedTools),
"argument-hint": data["argument-hint"] || "",
model: data.model || "",
"user-invocable": data["user-invocable"] !== false,
"disable-model-invocation": Boolean(data["disable-model-invocation"]),
context: data.context || "",
agent: data.agent || "",
author: data.author || "",
"author-email": data["author-email"] || "",
"fork-of": data["fork-of"] || "",
tags: parseTools(data.tags),
hooks: typeof data.hooks === "object" && data.hooks !== null ? data.hooks : null,
content: content.trim(),
raw
};
}
async function listSkills() {
await fs.mkdir(SKILLS_DIR, { recursive: true });
const files = await fs.readdir(SKILLS_DIR);
const skills = [];
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));
}
async function getAllTags() {
const all = await listSkills();
return [...new Set(all.flatMap((s) => s.tags))].sort();
}
async function getForksOf(slug) {
const all = await listSkills();
return all.filter((s) => s["fork-of"] === slug);
}
async function getSkill(slug) {
try {
const raw = await fs.readFile(skillPath(slug), "utf-8");
return parseSkill(slug, raw);
} catch {
return null;
}
}
async function createSkill(slug, content) {
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);
}
async function updateSkill(slug, content) {
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);
}
async function deleteSkill(slug) {
const dest = skillPath(slug);
try {
await fs.access(dest);
} catch {
throw new Error(`Skill not found: ${slug}`);
}
await fs.unlink(dest);
}
export { getAllTags as a, getForksOf as b, createSkill as c, deleteSkill as d, getSkill as g, isValidSlug as i, listSkills as l, updateSkill as u };

View File

@@ -1,46 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const STATS_FILE = path.resolve(process.env.STATS_FILE || "data/stats.json");
async function readStore() {
try {
const raw = await fs.readFile(STATS_FILE, "utf-8");
return JSON.parse(raw);
} catch {
return {};
}
}
async function writeStore(store) {
const dir = path.dirname(STATS_FILE);
await fs.mkdir(dir, { recursive: true });
const tmp = STATS_FILE + ".tmp";
await fs.writeFile(tmp, JSON.stringify(store, null, 2), "utf-8");
await fs.rename(tmp, STATS_FILE);
}
function ensure(store, slug) {
if (!store[slug]) {
store[slug] = { downloads: 0, pushes: 0, lastPushedAt: null };
}
return store[slug];
}
async function recordDownload(slug) {
const store = await readStore();
ensure(store, slug).downloads++;
await writeStore(store);
}
async function recordPush(slug) {
const store = await readStore();
const entry = ensure(store, slug);
entry.pushes++;
entry.lastPushedAt = (/* @__PURE__ */ new Date()).toISOString();
await writeStore(store);
}
async function getStatsForSlug(slug) {
const store = await readStore();
return store[slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
}
async function getAllStats() {
return readStore();
}
export { recordDownload as a, getAllStats as b, getStatsForSlug as g, recordPush as r };

View File

@@ -1,309 +0,0 @@
import { l as listSkills } from './skills_BacVQUiS.mjs';
function isPowerShell(request) {
const ua = request.headers.get("user-agent") || "";
return /PowerShell/i.test(ua);
}
async function buildPushScript(baseUrl, skillsDir) {
const lines = [
"#!/usr/bin/env bash",
"set -euo pipefail",
"",
`SKILLS_DIR="${skillsDir}"`,
`BASE_URL="${baseUrl}"`,
'FILTER="${1:-}"',
'TOKEN_FILE="$HOME/.claude/skills.here-token"',
"",
"# Get git author if available",
'AUTHOR_NAME=$(git config user.name 2>/dev/null || echo "")',
'AUTHOR_EMAIL=$(git config user.email 2>/dev/null || echo "")',
"",
"# Load or register token",
'TOKEN=""',
'if [ -f "$TOKEN_FILE" ]; then',
' TOKEN=$(cat "$TOKEN_FILE")',
'elif [ -n "$AUTHOR_EMAIL" ]; then',
' echo "No token found. Registering with $AUTHOR_EMAIL..."',
" REGISTER_RESPONSE=$(curl -sS -X POST \\",
' -H "Content-Type: application/json" \\',
' -d "{\\"email\\": \\"$AUTHOR_EMAIL\\", \\"name\\": \\"$AUTHOR_NAME\\"}" \\',
' -w "\\n%{http_code}" \\',
' "$BASE_URL/api/auth/register")',
` REGISTER_BODY=$(echo "$REGISTER_RESPONSE" | sed '$d')`,
' REGISTER_STATUS=$(echo "$REGISTER_RESPONSE" | tail -1)',
' if [ "$REGISTER_STATUS" = "201" ]; then',
' TOKEN=$(echo "$REGISTER_BODY" | jq -r .token)',
' mkdir -p "$(dirname "$TOKEN_FILE")"',
' echo "$TOKEN" > "$TOKEN_FILE"',
' chmod 600 "$TOKEN_FILE"',
' echo " Token saved to $TOKEN_FILE"',
' elif [ "$REGISTER_STATUS" = "409" ]; then',
' echo " Email already registered. Place your token in $TOKEN_FILE"',
' echo " Continuing without token (unprotected skills only)..."',
" else",
' echo " Registration failed ($REGISTER_STATUS): $REGISTER_BODY"',
' echo " Continuing without token (unprotected skills only)..."',
" fi",
"fi",
"",
'if [ ! -d "$SKILLS_DIR" ]; then',
' echo "No skills directory found at $SKILLS_DIR"',
" exit 1",
"fi",
"",
'AUTH_HEADER=""',
'if [ -n "$TOKEN" ]; then',
' AUTH_HEADER="Authorization: Bearer $TOKEN"',
"fi",
"",
"push_skill() {",
' local file="$1"',
' local slug=$(basename "$file" .md)',
' local content=$(cat "$file")',
"",
" # Inject author into frontmatter if available and not already present",
' if [ -n "$AUTHOR_EMAIL" ] && ! echo "$content" | grep -q "^author-email:"; then',
` content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" 'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}')`,
" fi",
"",
" # Build curl auth args",
' local auth_args=""',
' if [ -n "$AUTH_HEADER" ]; then',
' auth_args="-H \\"$AUTH_HEADER\\""',
" fi",
"",
" # Try PUT (update), fallback to POST (create)",
" local response",
' if [ -n "$TOKEN" ]; then',
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
" else",
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
" fi",
"",
' if [ "$response" = "403" ]; then',
' echo " ✗ $slug (permission denied — token missing or invalid)"',
" return 1",
" fi",
"",
' if [ "$response" = "404" ]; then',
' if [ -n "$TOKEN" ]; then',
' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
" else",
' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
" fi",
' if [ "$post_status" = "403" ]; then',
' echo " ✗ $slug (permission denied — token missing or invalid)"',
" return 1",
" fi",
" fi",
"",
' echo " ✓ $slug"',
"}",
"",
"count=0",
"failed=0",
'if [ -n "$FILTER" ]; then',
" # Push a specific skill",
' file="$SKILLS_DIR/${FILTER%.md}.md"',
' if [ ! -f "$file" ]; then',
' echo "Skill not found: $file"',
" exit 1",
" fi",
' if push_skill "$file"; then',
" count=1",
" else",
" failed=1",
" fi",
"else",
" # Push all skills",
' for file in "$SKILLS_DIR"/*.md; do',
' [ -f "$file" ] || continue',
' if push_skill "$file"; then',
" count=$((count + 1))",
" else",
" failed=$((failed + 1))",
" fi",
" done",
"fi",
"",
'echo "Pushed $count skill(s) to $BASE_URL"',
'if [ "$failed" -gt 0 ]; then',
' echo "$failed skill(s) failed (permission denied)"',
"fi",
""
];
return lines.join("\n");
}
async function buildSyncScript(baseUrl, skillsDir) {
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");
}
async function buildSyncScriptPS(baseUrl, skillsDir) {
const skills = await listSkills();
const lines = [
'$ErrorActionPreference = "Stop"',
"",
`$SkillsDir = "${skillsDir}"`,
"New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null",
""
];
if (skills.length === 0) {
lines.push('Write-Host "No skills available to sync."');
} else {
lines.push(`Write-Host "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push("");
for (const skill of skills) {
const skillUrl = `${baseUrl}/${skill.slug}`;
lines.push(`Invoke-WebRequest -Uri "${skillUrl}" -OutFile (Join-Path $SkillsDir "${skill.slug}.md")`);
lines.push(`Write-Host " ✓ ${skill.name}"`);
}
lines.push("");
lines.push('Write-Host "Done! Skills synced to $SkillsDir"');
}
lines.push("");
return lines.join("\n");
}
async function buildPushScriptPS(baseUrl, skillsDir) {
const lines = [
'$ErrorActionPreference = "Stop"',
"",
`$SkillsDir = "${skillsDir}"`,
`$BaseUrl = "${baseUrl}"`,
'$Filter = if ($args.Count -gt 0) { $args[0] } else { "" }',
'$TokenFile = Join-Path $HOME ".claude\\skills.here-token"',
"",
"# Get git author if available",
'$AuthorName = try { git config user.name 2>$null } catch { "" }',
'$AuthorEmail = try { git config user.email 2>$null } catch { "" }',
"",
"# Load or register token",
'$Token = ""',
"if (Test-Path $TokenFile) {",
" $Token = (Get-Content $TokenFile -Raw).Trim()",
"} elseif ($AuthorEmail) {",
' Write-Host "No token found. Registering with $AuthorEmail..."',
" try {",
" $body = @{ email = $AuthorEmail; name = $AuthorName } | ConvertTo-Json",
' $resp = Invoke-WebRequest -Uri "$BaseUrl/api/auth/register" -Method POST -ContentType "application/json" -Body $body',
" if ($resp.StatusCode -eq 201) {",
" $Token = ($resp.Content | ConvertFrom-Json).token",
" New-Item -ItemType Directory -Force -Path (Split-Path $TokenFile) | Out-Null",
" Set-Content -Path $TokenFile -Value $Token",
' Write-Host " Token saved to $TokenFile"',
" }",
" } catch {",
" $code = $_.Exception.Response.StatusCode.value__",
" if ($code -eq 409) {",
' Write-Host " Email already registered. Place your token in $TokenFile"',
" } else {",
' Write-Host " Registration failed: $_"',
" }",
' Write-Host " Continuing without token (unprotected skills only)..."',
" }",
"}",
"",
"if (-not (Test-Path $SkillsDir)) {",
' Write-Host "No skills directory found at $SkillsDir"',
" exit 1",
"}",
"",
'$headers = @{ "Content-Type" = "application/json" }',
'if ($Token) { $headers["Authorization"] = "Bearer $Token" }',
"",
"function Push-Skill($file) {",
" $slug = [IO.Path]::GetFileNameWithoutExtension($file)",
" $content = Get-Content $file -Raw",
"",
" # Inject author if available and not present",
' if ($AuthorEmail -and $content -notmatch "(?m)^author-email:") {',
' $inject = ""',
' if ($AuthorName) { $inject += "author: $AuthorName`n" }',
' $inject += "author-email: $AuthorEmail"',
' $content = $content -replace "^---$", "---`n$inject" -replace "^---`n", "---`n"',
" }",
"",
" $body = @{ content = $content } | ConvertTo-Json",
" try {",
' Invoke-WebRequest -Uri "$BaseUrl/api/skills/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' Write-Host " ✓ $slug"',
" return $true",
" } catch {",
" $code = $_.Exception.Response.StatusCode.value__",
" if ($code -eq 403) {",
' Write-Host " ✗ $slug (permission denied)"',
" return $false",
" }",
" if ($code -eq 404) {",
" $postBody = @{ slug = $slug; content = $content } | ConvertTo-Json",
" try {",
' Invoke-WebRequest -Uri "$BaseUrl/api/skills" -Method POST -Headers $headers -Body $postBody | Out-Null',
' Write-Host " ✓ $slug"',
" return $true",
" } catch {",
" $postCode = $_.Exception.Response.StatusCode.value__",
" if ($postCode -eq 403) {",
' Write-Host " ✗ $slug (permission denied)"',
" return $false",
" }",
" }",
" }",
" }",
' Write-Host " ✓ $slug"',
" return $true",
"}",
"",
"$count = 0; $failed = 0",
"if ($Filter) {",
` $file = Join-Path $SkillsDir "$($Filter -replace '\\.md$','').md"`,
' if (-not (Test-Path $file)) { Write-Host "Skill not found: $file"; exit 1 }',
" if (Push-Skill $file) { $count++ } else { $failed++ }",
"} else {",
' Get-ChildItem -Path $SkillsDir -Filter "*.md" | ForEach-Object {',
" if (Push-Skill $_.FullName) { $count++ } else { $failed++ }",
" }",
"}",
"",
'Write-Host "Pushed $count skill(s) to $BaseUrl"',
'if ($failed -gt 0) { Write-Host "$failed skill(s) failed (permission denied)" }',
""
];
return lines.join("\n");
}
export { buildSyncScriptPS as a, buildSyncScript as b, buildPushScript as c, buildPushScriptPS as d, isPowerShell as i };

View File

@@ -1,55 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
const TOKENS_FILE = path.resolve(process.env.TOKENS_FILE || "data/tokens.json");
async function readStore() {
try {
const raw = await fs.readFile(TOKENS_FILE, "utf-8");
return JSON.parse(raw);
} catch {
return {};
}
}
async function writeStore(store) {
const dir = path.dirname(TOKENS_FILE);
await fs.mkdir(dir, { recursive: true });
const tmp = TOKENS_FILE + ".tmp";
await fs.writeFile(tmp, JSON.stringify(store, null, 2), "utf-8");
await fs.rename(tmp, TOKENS_FILE);
}
function hashToken(token) {
return crypto.createHash("sha256").update(token).digest("hex");
}
async function generateToken(email, name) {
const store = await readStore();
if (store[email]) {
throw new Error("Email already registered");
}
const token = crypto.randomBytes(32).toString("hex");
store[email] = {
hash: hashToken(token),
name,
createdAt: (/* @__PURE__ */ new Date()).toISOString()
};
await writeStore(store);
return token;
}
async function verifyToken(email, token) {
if (!email || !token) return false;
const store = await readStore();
const entry = store[email];
if (!entry) return false;
return entry.hash === hashToken(token);
}
async function hasToken(email) {
const store = await readStore();
return Boolean(store[email]);
}
function extractBearerToken(request) {
const auth = request.headers.get("Authorization") || "";
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : "";
}
export { extractBearerToken as e, generateToken as g, hasToken as h, verifyToken as v };

69
dist/server/entry.mjs vendored
View File

@@ -1,69 +0,0 @@
import { renderers } from './renderers.mjs';
import { c as createExports, s as serverEntrypointModule } from './chunks/_@astrojs-ssr-adapter_BeL8VyJ8.mjs';
import { manifest } from './manifest_Bz0Ba_R4.mjs';
const serverIslandMap = new Map();;
const _page0 = () => import('./pages/_image.astro.mjs');
const _page1 = () => import('./pages/api/auth/register.astro.mjs');
const _page2 = () => import('./pages/api/auth/verify.astro.mjs');
const _page3 = () => import('./pages/api/skills/_slug_.astro.mjs');
const _page4 = () => import('./pages/api/skills.astro.mjs');
const _page5 = () => import('./pages/api/sync/project.astro.mjs');
const _page6 = () => import('./pages/api/sync.astro.mjs');
const _page7 = () => import('./pages/gi.astro.mjs');
const _page8 = () => import('./pages/gp.astro.mjs');
const _page9 = () => import('./pages/i.astro.mjs');
const _page10 = () => import('./pages/new.astro.mjs');
const _page11 = () => import('./pages/p.astro.mjs');
const _page12 = () => import('./pages/_slug_/edit.astro.mjs');
const _page13 = () => import('./pages/_slug_/gi.astro.mjs');
const _page14 = () => import('./pages/_slug_/i.astro.mjs');
const _page15 = () => import('./pages/_slug_.astro.mjs');
const _page16 = () => import('./pages/index.astro.mjs');
const pageMap = new Map([
["node_modules/astro/dist/assets/endpoint/node.js", _page0],
["src/pages/api/auth/register.ts", _page1],
["src/pages/api/auth/verify.ts", _page2],
["src/pages/api/skills/[slug].ts", _page3],
["src/pages/api/skills/index.ts", _page4],
["src/pages/api/sync/project.ts", _page5],
["src/pages/api/sync/index.ts", _page6],
["src/pages/gi.ts", _page7],
["src/pages/gp.ts", _page8],
["src/pages/i.ts", _page9],
["src/pages/new.astro", _page10],
["src/pages/p.ts", _page11],
["src/pages/[slug]/edit.astro", _page12],
["src/pages/[slug]/gi.ts", _page13],
["src/pages/[slug]/i.ts", _page14],
["src/pages/[slug].astro", _page15],
["src/pages/index.astro", _page16]
]);
const _manifest = Object.assign(manifest, {
pageMap,
serverIslandMap,
renderers,
actions: () => import('./noop-entrypoint.mjs'),
middleware: () => import('./_noop-middleware.mjs')
});
const _args = {
"mode": "standalone",
"client": "file:///Users/alex/projects/skillit/dist/client/",
"server": "file:///Users/alex/projects/skillit/dist/server/",
"host": false,
"port": 4321,
"assets": "_astro",
"experimentalStaticHeaders": false
};
const _exports = createExports(_manifest, _args);
const handler = _exports['handler'];
const startServer = _exports['startServer'];
const options = _exports['options'];
const _start = 'start';
if (Object.prototype.hasOwnProperty.call(serverEntrypointModule, _start)) {
serverEntrypointModule[_start](_manifest, _args);
}
export { handler, options, pageMap, startServer };

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
const server = {};
export { server };

View File

@@ -1,2 +0,0 @@
export { a as page } from '../chunks/node_HH9e2ntY.mjs';
export { renderers } from '../renderers.mjs';

View File

@@ -1,292 +0,0 @@
import { e as createAstro, f as createComponent, k as renderComponent, l as renderScript, r as renderTemplate, m as maybeRenderHead, h as addAttribute, u as unescapeHTML } from '../chunks/astro/server_CF97kUu8.mjs';
import 'piccolore';
import { _ as _export_sfc, $ as $$Base } from '../chunks/_plugin-vue_export-helper_CEgY73aA.mjs';
import { useSSRContext, defineComponent, ref, nextTick, mergeProps } from 'vue';
import { ssrRenderTeleport, ssrInterpolate, ssrRenderAttr, ssrIncludeBooleanAttr, ssrRenderAttrs } from 'vue/server-renderer';
import { g as getSkill, b as getForksOf } from '../chunks/skills_BacVQUiS.mjs';
import { h as hasToken } from '../chunks/tokens_CAzj9Aj8.mjs';
import { a as recordDownload, g as getStatsForSlug } from '../chunks/stats_CaDi9y9J.mjs';
import { marked } from 'marked';
/* empty css */
export { renderers } from '../renderers.mjs';
const _sfc_main$1 = /* @__PURE__ */ defineComponent({
__name: "EditGate",
props: {
slug: {},
authorEmail: {},
authorName: {},
authorHasToken: { type: Boolean }
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
const showModal = ref(false);
const token = ref("");
const error = ref("");
const verifying = ref(false);
const tokenInput = ref();
async function handleClick() {
if (!props.authorEmail || !props.authorHasToken) {
window.location.href = `/${props.slug}/edit`;
return;
}
const saved = localStorage.getItem("skillshere-token") || "";
if (saved) {
try {
const res = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: props.authorEmail, token: saved })
});
if (res.ok) {
localStorage.setItem("skillshere-token", saved);
window.location.href = `/${props.slug}/edit`;
return;
}
} catch {
}
}
showModal.value = true;
error.value = "";
token.value = "";
nextTick(() => tokenInput.value?.focus());
}
async function verify() {
verifying.value = true;
error.value = "";
try {
const res = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: props.authorEmail, token: token.value })
});
if (!res.ok) {
const data = await res.json();
error.value = data.error || "Invalid token";
return;
}
localStorage.setItem("skillshere-token", token.value);
window.location.href = `/${props.slug}/edit`;
} catch {
error.value = "Could not verify token";
} finally {
verifying.value = false;
}
}
function forkSkill() {
showModal.value = false;
window.location.href = `/new?from=${encodeURIComponent(props.slug)}`;
}
const __returned__ = { props, showModal, token, error, verifying, tokenInput, handleClick, verify, forkSkill };
Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
return __returned__;
}
});
function _sfc_ssrRender$1(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
_push(`<!--[--><button 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"></path></svg> Edit </button>`);
ssrRenderTeleport(_push, (_push2) => {
if ($setup.showModal) {
_push2(`<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"><div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl"><h3 class="text-lg font-semibold text-white mb-1">Author Verification</h3><p class="text-sm text-gray-500 mb-4"> This skill is owned by <strong class="text-gray-300">${ssrInterpolate($props.authorName || $props.authorEmail)}</strong>. Enter your token to edit. </p><form><input${ssrRenderAttr("value", $setup.token)} type="password" placeholder="Paste your author token..." 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 font-mono focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all">`);
if ($setup.error) {
_push2(`<p class="mt-2 text-sm text-red-400">${ssrInterpolate($setup.error)}</p>`);
} else {
_push2(`<!---->`);
}
_push2(`<div class="mt-4 flex items-center gap-3"><button type="submit"${ssrIncludeBooleanAttr($setup.verifying || !$setup.token) ? " disabled" : ""} class="inline-flex items-center gap-2 rounded-xl bg-[var(--color-accent-500)] px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-[var(--color-accent-500)]/20 hover:bg-[var(--color-accent-600)] disabled:opacity-50 active:scale-[0.97] transition-all">`);
if ($setup.verifying) {
_push2(`<svg 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"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>`);
} else {
_push2(`<!---->`);
}
_push2(` ${ssrInterpolate($setup.verifying ? "Verifying..." : "Continue to Edit")}</button><button type="button" class="text-sm text-[var(--color-accent-400)] hover:text-[var(--color-accent-300)] transition-colors"> Fork instead </button><button type="button" class="ml-auto text-sm text-gray-600 hover:text-gray-300 transition-colors"> Cancel </button></div></form></div></div>`);
} else {
_push2(`<!---->`);
}
}, "body", false, _parent);
_push(`<!--]-->`);
}
const _sfc_setup$1 = _sfc_main$1.setup;
_sfc_main$1.setup = (props, ctx) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/components/EditGate.vue");
return _sfc_setup$1 ? _sfc_setup$1(props, ctx) : void 0;
};
const EditGate = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["ssrRender", _sfc_ssrRender$1]]);
const _sfc_main = /* @__PURE__ */ defineComponent({
__name: "DeleteButton",
props: {
slug: {},
authorEmail: {},
authorName: {},
authorHasToken: { type: Boolean }
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
const deleting = ref(false);
const showModal = ref(false);
const token = ref("");
const error = ref("");
const tokenInput = ref();
async function handleClick() {
if (props.authorEmail && props.authorHasToken) {
const saved = localStorage.getItem("skillshere-token") || "";
if (saved) {
try {
const res = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: props.authorEmail, token: saved })
});
if (res.ok) {
doDelete(saved);
return;
}
} catch {
}
}
showModal.value = true;
error.value = "";
token.value = "";
nextTick(() => tokenInput.value?.focus());
} else {
doDelete("");
}
}
async function verifyAndDelete() {
error.value = "";
deleting.value = true;
try {
const verifyRes = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: props.authorEmail, token: token.value })
});
if (!verifyRes.ok) {
const data = await verifyRes.json();
error.value = data.error || "Invalid token";
deleting.value = false;
return;
}
} catch {
error.value = "Could not verify token";
deleting.value = false;
return;
}
localStorage.setItem("skillshere-token", token.value);
doDelete(token.value);
}
async function doDelete(authToken) {
if (!confirm(`Delete "${props.slug}"? This cannot be undone.`)) {
deleting.value = false;
return;
}
deleting.value = true;
error.value = "";
try {
const headers = {};
if (authToken) {
headers["Authorization"] = `Bearer ${authToken}`;
}
const res = await fetch(`/api/skills/${props.slug}`, { method: "DELETE", headers });
if (res.status === 403) {
const data = await res.json();
error.value = data.error || "Permission denied";
showModal.value = true;
deleting.value = false;
return;
}
if (!res.ok && res.status !== 204) {
const data = await res.json().catch(() => ({ error: "Failed to delete" }));
throw new Error(data.error || "Failed to delete");
}
window.location.href = "/";
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to delete skill.";
deleting.value = false;
}
}
const __returned__ = { props, deleting, showModal, token, error, tokenInput, handleClick, verifyAndDelete, doDelete };
Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
return __returned__;
}
});
function _sfc_ssrRender(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
_push(`<div${ssrRenderAttrs(mergeProps({ class: "inline-flex" }, _attrs))}><button${ssrIncludeBooleanAttr($setup.deleting) ? " disabled" : ""} 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"></path></svg> ${ssrInterpolate($setup.deleting ? "Deleting..." : "Delete")}</button>`);
ssrRenderTeleport(_push, (_push2) => {
if ($setup.showModal) {
_push2(`<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"><div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl"><h3 class="text-lg font-semibold text-red-400 mb-1">Delete Skill</h3><p class="text-sm text-gray-500 mb-4"> This skill is owned by <strong class="text-gray-300">${ssrInterpolate($props.authorName || $props.authorEmail)}</strong>. Enter your token to delete it. </p><form><input${ssrRenderAttr("value", $setup.token)} type="password" placeholder="Paste your author token..." 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 font-mono focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/20 transition-all">`);
if ($setup.error) {
_push2(`<p class="mt-2 text-sm text-red-400">${ssrInterpolate($setup.error)}</p>`);
} else {
_push2(`<!---->`);
}
_push2(`<div class="mt-4 flex items-center gap-3"><button type="submit"${ssrIncludeBooleanAttr($setup.deleting || !$setup.token) ? " disabled" : ""} class="inline-flex items-center gap-2 rounded-xl bg-red-500 px-5 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:opacity-50 active:scale-[0.97] transition-all">`);
if ($setup.deleting) {
_push2(`<svg 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"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>`);
} else {
_push2(`<!---->`);
}
_push2(` ${ssrInterpolate($setup.deleting ? "Deleting..." : "Delete Permanently")}</button><button type="button" class="ml-auto text-sm text-gray-600 hover:text-gray-300 transition-colors"> Cancel </button></div></form></div></div>`);
} else {
_push2(`<!---->`);
}
}, "body", false, _parent);
_push(`</div>`);
}
const _sfc_setup = _sfc_main.setup;
_sfc_main.setup = (props, ctx) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/components/DeleteButton.vue");
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
};
const DeleteButton = /* @__PURE__ */ _export_sfc(_sfc_main, [["ssrRender", _sfc_ssrRender]]);
const $$Astro = createAstro("https://skills.here.run.place");
const $$slug = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$slug;
const { slug } = Astro2.params;
const skill = await getSkill(slug);
if (!skill) {
return Astro2.redirect("/");
}
const accept = Astro2.request.headers.get("accept") || "";
if (!accept.includes("text/html")) {
recordDownload(slug);
return new Response(skill.raw, {
headers: { "Content-Type": "text/markdown; charset=utf-8" }
});
}
const authorHasToken = skill["author-email"] ? await hasToken(skill["author-email"]) : false;
const forks = await getForksOf(slug);
const stats = await getStatsForSlug(slug);
const html = await marked(skill.content);
const origin = Astro2.url.origin;
const cmds = {
unix: `curl -fsSL ${origin}/${slug}/i | bash`,
unixGlobal: `curl -fsSL ${origin}/${slug}/gi | bash`,
win: `irm ${origin}/${slug}/i | iex`,
winGlobal: `irm ${origin}/${slug}/gi | iex`
};
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": `${skill.name} \u2014 Skills Here`, "data-astro-cid-yvbahnfj": true }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4" data-astro-cid-yvbahnfj> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" data-astro-cid-yvbahnfj> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" data-astro-cid-yvbahnfj></path> </svg>
Back
</a> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;" data-astro-cid-yvbahnfj> <div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6" style="min-width: 0;" data-astro-cid-yvbahnfj> <h1 class="text-2xl font-bold tracking-tight text-white mb-1" data-astro-cid-yvbahnfj>${skill.name}</h1> ${skill.description && renderTemplate`<p class="text-gray-500 leading-relaxed mb-3" data-astro-cid-yvbahnfj>${skill.description}</p>`} ${skill["allowed-tools"].length > 0 && renderTemplate`<div class="flex flex-wrap gap-1.5" data-astro-cid-yvbahnfj> ${skill["allowed-tools"].map((tool) => renderTemplate`<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" data-astro-cid-yvbahnfj> ${tool} </span>`)} </div>`} ${skill.tags.length > 0 && renderTemplate`<div class="flex flex-wrap gap-1.5 mt-3" data-astro-cid-yvbahnfj> ${skill.tags.map((tag) => renderTemplate`<span class="rounded-full bg-[var(--color-accent-500)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]" data-astro-cid-yvbahnfj> ${tag} </span>`)} </div>`} ${skill.author && renderTemplate`<p class="text-xs text-gray-600 mt-3" data-astro-cid-yvbahnfj>by ${skill.author}</p>`} ${skill["fork-of"] && renderTemplate`<p class="text-xs text-gray-600 mt-1" data-astro-cid-yvbahnfj>forked from <a${addAttribute(`/${skill["fork-of"]}`, "href")} class="text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors" data-astro-cid-yvbahnfj>${skill["fork-of"]}</a></p>`} ${forks.length > 0 && renderTemplate`<details class="mt-3" data-astro-cid-yvbahnfj> <summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300 transition-colors select-none" data-astro-cid-yvbahnfj> ${forks.length} fork${forks.length !== 1 ? "s" : ""} </summary> <ul class="mt-1.5 space-y-1 pl-3 border-l border-white/[0.06]" data-astro-cid-yvbahnfj> ${forks.map((f) => renderTemplate`<li data-astro-cid-yvbahnfj> <a${addAttribute(`/${f.slug}`, "href")} class="text-xs text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors" data-astro-cid-yvbahnfj> ${f.name} ${f.author && renderTemplate`<span class="text-gray-600" data-astro-cid-yvbahnfj> by ${f.author}</span>`} </a> </li>`)} </ul> </details>`} <div class="flex flex-wrap items-center gap-4 mt-4 pt-3 border-t border-white/[0.06]" data-astro-cid-yvbahnfj> <span class="inline-flex items-center gap-1.5 text-xs text-gray-500" data-astro-cid-yvbahnfj> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" data-astro-cid-yvbahnfj> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" data-astro-cid-yvbahnfj></path> </svg> ${stats.downloads} download${stats.downloads !== 1 ? "s" : ""} </span> <span class="inline-flex items-center gap-1.5 text-xs text-gray-500" data-astro-cid-yvbahnfj> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" data-astro-cid-yvbahnfj> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" data-astro-cid-yvbahnfj></path> </svg> ${stats.pushes} push${stats.pushes !== 1 ? "es" : ""} </span> ${stats.lastPushedAt && renderTemplate`<span class="text-xs text-gray-600" data-astro-cid-yvbahnfj>
Last updated ${new Date(stats.lastPushedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} </span>`} </div> </div> <div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 space-y-4" style="min-width: 0;" data-astro-cid-yvbahnfj> <div class="flex items-center justify-between" data-astro-cid-yvbahnfj> <h2 class="text-sm font-semibold text-white" data-astro-cid-yvbahnfj>Install this skill</h2> <div class="flex rounded-lg border border-white/[0.06] overflow-hidden" id="os-tabs" data-astro-cid-yvbahnfj> <button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all" data-astro-cid-yvbahnfj>macOS / Linux</button> <button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all" data-astro-cid-yvbahnfj>Windows</button> </div> </div> <p class="text-xs text-gray-500 leading-relaxed" data-astro-cid-yvbahnfj>Run in your project root to add this skill.</p> <div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3" data-astro-cid-yvbahnfj> <code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-500 select-all truncate" data-astro-cid-yvbahnfj>${cmds.unix}</code> <code data-cmd="win" class="flex-1 text-xs font-mono text-gray-500 select-all truncate hidden" data-astro-cid-yvbahnfj>${cmds.win}</code> <button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all" data-astro-cid-yvbahnfj>Copy</button> </div> <details class="group" data-astro-cid-yvbahnfj> <summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors" data-astro-cid-yvbahnfj>More options</summary> <div class="mt-3 space-y-3 text-xs text-gray-500" data-astro-cid-yvbahnfj> <div data-astro-cid-yvbahnfj> <p class="mb-1.5" data-astro-cid-yvbahnfj>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" data-astro-cid-yvbahnfj> <code data-cmd="unix" class="flex-1 font-mono text-gray-500 select-all truncate" data-astro-cid-yvbahnfj>${cmds.unixGlobal}</code> <code data-cmd="win" class="flex-1 font-mono text-gray-500 select-all truncate hidden" data-astro-cid-yvbahnfj>${cmds.winGlobal}</code> <button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all" data-astro-cid-yvbahnfj>Copy</button> </div> </div> </div> </details> </div> </div> <div class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8" data-astro-cid-yvbahnfj> <div class="absolute top-4 right-4 flex items-center gap-2" data-astro-cid-yvbahnfj> ${renderComponent($$result2, "EditGate", EditGate, { "slug": slug, "authorEmail": skill["author-email"], "authorName": skill.author, "authorHasToken": authorHasToken, "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/EditGate.vue", "client:component-export": "default", "data-astro-cid-yvbahnfj": true })} ${renderComponent($$result2, "DeleteButton", DeleteButton, { "slug": slug, "authorEmail": skill["author-email"], "authorName": skill.author, "authorHasToken": authorHasToken, "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/DeleteButton.vue", "client:component-export": "default", "data-astro-cid-yvbahnfj": true })} </div> <article class="skill-prose" data-astro-cid-yvbahnfj>${unescapeHTML(html)}</article> </div> ` })} ${renderScript($$result, "/Users/alex/projects/skillit/src/pages/[slug].astro?astro&type=script&index=0&lang.ts")}`;
}, "/Users/alex/projects/skillit/src/pages/[slug].astro", void 0);
const $$file = "/Users/alex/projects/skillit/src/pages/[slug].astro";
const $$url = "/[slug]";
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
default: $$slug,
file: $$file,
url: $$url
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,38 +0,0 @@
import { e as createAstro, f as createComponent, k as renderComponent, r as renderTemplate, m as maybeRenderHead, h as addAttribute } from '../../chunks/astro/server_CF97kUu8.mjs';
import 'piccolore';
import { $ as $$Base } from '../../chunks/_plugin-vue_export-helper_CEgY73aA.mjs';
import { g as getAvailableTools, a as getAvailableModels, S as SkillEditor } from '../../chunks/models_BK7lP4G3.mjs';
import { g as getSkill, a as getAllTags } from '../../chunks/skills_BacVQUiS.mjs';
export { renderers } from '../../renderers.mjs';
const $$Astro = createAstro("https://skills.here.run.place");
const $$Edit = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$Edit;
const { slug } = Astro2.params;
const skill = await getSkill(slug);
if (!skill) {
return Astro2.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) : "";
const availableTags = await getAllTags();
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": `Edit ${skill.name} \u2014 Skills Here` }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<a${addAttribute(`/${slug}`, "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"></path> </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> ${renderComponent($$result2, "SkillEditor", 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, "initialAuthor": skill.author, "initialAuthorEmail": skill["author-email"], "initialTags": skill.tags.join(", "), ":availableTools": availableTools, ":availableModels": availableModels, "availableTags": availableTags.join(","), "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/SkillEditor.vue", "client:component-export": "default" })} ` })}`;
}, "/Users/alex/projects/skillit/src/pages/[slug]/edit.astro", void 0);
const $$file = "/Users/alex/projects/skillit/src/pages/[slug]/edit.astro";
const $$url = "/[slug]/edit";
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
default: $$Edit,
file: $$file,
url: $$url
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,40 +0,0 @@
import { g as getSkill } from '../../chunks/skills_BacVQUiS.mjs';
import { i as isPowerShell } from '../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../renderers.mjs';
const GET = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug);
if (!skill) {
return new Response("Skill not found", { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const script = ps ? [
'$ErrorActionPreference = "Stop"',
'$Dir = Join-Path $HOME ".claude\\skills"',
"New-Item -ItemType Directory -Force -Path $Dir | Out-Null",
`Invoke-WebRequest -Uri "${origin}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} globally to $Dir\\${slug}.md"`,
""
].join("\n") : [
"#!/usr/bin/env bash",
"set -euo pipefail",
"mkdir -p ~/.claude/skills",
`curl -fsSL "${origin}/${slug}" -o ~/.claude/skills/${slug}.md`,
`echo "✓ Installed ${skill.name} globally to ~/.claude/skills/${slug}.md"`,
""
].join("\n");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,40 +0,0 @@
import { g as getSkill } from '../../chunks/skills_BacVQUiS.mjs';
import { i as isPowerShell } from '../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../renderers.mjs';
const GET = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug);
if (!skill) {
return new Response("Skill not found", { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const script = ps ? [
'$ErrorActionPreference = "Stop"',
'$Dir = ".claude\\skills"',
"New-Item -ItemType Directory -Force -Path $Dir | Out-Null",
`Invoke-WebRequest -Uri "${origin}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} to $Dir\\${slug}.md"`,
""
].join("\n") : [
"#!/usr/bin/env bash",
"set -euo pipefail",
"mkdir -p .claude/skills",
`curl -fsSL "${origin}/${slug}" -o ".claude/skills/${slug}.md"`,
`echo "✓ Installed ${skill.name} to .claude/skills/${slug}.md"`,
""
].join("\n");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,55 +0,0 @@
import { h as hasToken, g as generateToken } from '../../../chunks/tokens_CAzj9Aj8.mjs';
export { renderers } from '../../../renderers.mjs';
const POST = async ({ request }) => {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
const { email, name } = body;
if (!email) {
return new Response(JSON.stringify({ error: "email is required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
if (await hasToken(email)) {
return new Response(JSON.stringify({ error: "Email already registered" }), {
status: 409,
headers: { "Content-Type": "application/json" }
});
}
try {
const token = await generateToken(email, name || "");
return new Response(JSON.stringify({ token }), {
status: 201,
headers: { "Content-Type": "application/json" }
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
if (message.includes("already registered")) {
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" }
});
}
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
POST
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,40 +0,0 @@
import { v as verifyToken } from '../../../chunks/tokens_CAzj9Aj8.mjs';
export { renderers } from '../../../renderers.mjs';
const POST = async ({ request }) => {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
const { email, token } = body;
if (!email || !token) {
return new Response(JSON.stringify({ error: "email and token are required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
const valid = await verifyToken(email, token);
if (!valid) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
POST
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,78 +0,0 @@
import matter from 'gray-matter';
import { l as listSkills, i as isValidSlug, c as createSkill } from '../../chunks/skills_BacVQUiS.mjs';
import { h as hasToken, e as extractBearerToken, v as verifyToken } from '../../chunks/tokens_CAzj9Aj8.mjs';
import { r as recordPush } from '../../chunks/stats_CaDi9y9J.mjs';
export { renderers } from '../../renderers.mjs';
const GET = async () => {
const skills = await listSkills();
return new Response(JSON.stringify(skills), {
headers: { "Content-Type": "application/json" }
});
};
const POST = async ({ request }) => {
let body;
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" }
});
}
const parsed = matter(content);
const authorEmail = parsed.data["author-email"] || "";
if (authorEmail && await hasToken(authorEmail)) {
const token = extractBearerToken(request);
const valid = await verifyToken(authorEmail, token);
if (!valid) {
return new Response(JSON.stringify({ error: "Valid token required to create a skill with author-email. Register first via POST /api/auth/register." }), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
}
try {
const skill = await createSkill(slug, content);
recordPush(slug);
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" }
});
}
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET,
POST
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,102 +0,0 @@
import 'gray-matter';
import { g as getSkill, d as deleteSkill, u as updateSkill } from '../../../chunks/skills_BacVQUiS.mjs';
import { h as hasToken, e as extractBearerToken, v as verifyToken } from '../../../chunks/tokens_CAzj9Aj8.mjs';
import { r as recordPush } from '../../../chunks/stats_CaDi9y9J.mjs';
export { renderers } from '../../../renderers.mjs';
const GET = 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" }
});
};
const PUT = async ({ params, request }) => {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
if (!body.content) {
return new Response(JSON.stringify({ error: "content is required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
try {
const existing = await getSkill(params.slug);
if (!existing) {
return new Response(JSON.stringify({ error: "Skill not found" }), {
status: 404,
headers: { "Content-Type": "application/json" }
});
}
if (existing["author-email"] && await hasToken(existing["author-email"])) {
const token = extractBearerToken(request);
const valid = await verifyToken(existing["author-email"], token);
if (!valid) {
return new Response(JSON.stringify({ error: `Only ${existing.author || existing["author-email"]} can update this skill. Provide a valid token via Authorization: Bearer header.` }), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
}
const skill = await updateSkill(params.slug, body.content);
recordPush(params.slug);
return new Response(JSON.stringify(skill), {
headers: { "Content-Type": "application/json" }
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
};
const DELETE = async ({ params, request }) => {
try {
const existing = await getSkill(params.slug);
if (!existing) {
return new Response(JSON.stringify({ error: "Skill not found" }), {
status: 404,
headers: { "Content-Type": "application/json" }
});
}
if (existing["author-email"] && await hasToken(existing["author-email"])) {
const token = extractBearerToken(request);
const valid = await verifyToken(existing["author-email"], token);
if (!valid) {
return new Response(JSON.stringify({ error: `Only ${existing.author || existing["author-email"]} can delete this skill. Provide a valid token via Authorization: Bearer header.` }), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
}
await deleteSkill(params.slug);
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
DELETE,
GET,
PUT
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,18 +0,0 @@
import { b as buildSyncScript } from '../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildSyncScript(url.origin, "$HOME/.claude/skills");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,18 +0,0 @@
import { b as buildSyncScript } from '../../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildSyncScript(url.origin, ".claude/skills");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,19 +0,0 @@
import { i as isPowerShell, a as buildSyncScriptPS, b as buildSyncScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url, request }) => {
const ps = isPowerShell(request);
const script = ps ? await buildSyncScriptPS(url.origin, "$HOME\\.claude\\skills") : await buildSyncScript(url.origin, "$HOME/.claude/skills");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,18 +0,0 @@
import { c as buildPushScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildPushScript(url.origin, "$HOME/.claude/skills");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,18 +0,0 @@
import { b as buildSyncScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildSyncScript(url.origin, ".claude/skills");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

File diff suppressed because one or more lines are too long

View File

@@ -1,37 +0,0 @@
import { e as createAstro, f as createComponent, k as renderComponent, r as renderTemplate, m as maybeRenderHead, h as addAttribute } from '../chunks/astro/server_CF97kUu8.mjs';
import 'piccolore';
import { $ as $$Base } from '../chunks/_plugin-vue_export-helper_CEgY73aA.mjs';
import { g as getAvailableTools, a as getAvailableModels, S as SkillEditor } from '../chunks/models_BK7lP4G3.mjs';
import { a as getAllTags, g as getSkill } from '../chunks/skills_BacVQUiS.mjs';
export { renderers } from '../renderers.mjs';
const $$Astro = createAstro("https://skills.here.run.place");
const $$New = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$New;
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
const availableTags = await getAllTags();
const fromSlug = Astro2.url.searchParams.get("from");
let forkSource = null;
if (fromSlug) {
forkSource = await getSkill(fromSlug);
}
const isFork = Boolean(forkSource);
const title = isFork ? `Fork ${forkSource.name} \u2014 Skills Here` : "New Skill \u2014 Skills Here";
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": title }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<a${addAttribute(isFork ? `/${fromSlug}` : "/", "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"></path> </svg> ${isFork ? `Back to ${forkSource.name}` : "Back"} </a> <h1 class="text-2xl font-bold tracking-tight text-white mb-2">${isFork ? "Fork Skill" : "New Skill"}</h1> ${isFork ? renderTemplate`<p class="text-sm text-gray-500 mb-8">Creating an independent copy of <strong class="text-gray-400">${forkSource.name}</strong>. Change the <strong class="text-gray-400">name</strong> to generate a new slug before saving.</p>` : renderTemplate`<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>`}${isFork ? renderTemplate`${renderComponent($$result2, "SkillEditor", SkillEditor, { "mode": "create", "forkOf": fromSlug, "initialName": forkSource.name, "initialDescription": forkSource.description, "initialAllowedTools": forkSource["allowed-tools"].join(", "), "initialArgumentHint": forkSource["argument-hint"], "initialModel": forkSource.model, "initialUserInvocable": forkSource["user-invocable"], "initialDisableModelInvocation": forkSource["disable-model-invocation"], "initialContext": forkSource.context, "initialAgent": forkSource.agent, "initialHooks": forkSource.hooks ? JSON.stringify(forkSource.hooks, null, 2) : "", "initialBody": forkSource.content, "initialTags": forkSource.tags.join(", "), ":availableTools": availableTools, ":availableModels": availableModels, "availableTags": availableTags.join(","), "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/SkillEditor.vue", "client:component-export": "default" })}` : renderTemplate`${renderComponent($$result2, "SkillEditor", SkillEditor, { "mode": "create", ":availableTools": availableTools, ":availableModels": availableModels, "availableTags": availableTags.join(","), "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/SkillEditor.vue", "client:component-export": "default" })}`}` })}`;
}, "/Users/alex/projects/skillit/src/pages/new.astro", void 0);
const $$file = "/Users/alex/projects/skillit/src/pages/new.astro";
const $$url = "/new";
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
default: $$New,
file: $$file,
url: $$url
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,19 +0,0 @@
import { i as isPowerShell, d as buildPushScriptPS, c as buildPushScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url, request }) => {
const ps = isPowerShell(request);
const script = ps ? await buildPushScriptPS(url.origin, ".claude\\skills") : await buildPushScript(url.origin, ".claude/skills");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,81 +0,0 @@
import { defineComponent, h, createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
const setup = () => {};
const contexts = /* @__PURE__ */ new WeakMap();
const ID_PREFIX = "s";
function getContext(rendererContextResult) {
if (contexts.has(rendererContextResult)) {
return contexts.get(rendererContextResult);
}
const ctx = {
currentIndex: 0,
get id() {
return ID_PREFIX + this.currentIndex.toString();
}
};
contexts.set(rendererContextResult, ctx);
return ctx;
}
function incrementId(rendererContextResult) {
const ctx = getContext(rendererContextResult);
const id = ctx.id;
ctx.currentIndex++;
return id;
}
const StaticHtml = defineComponent({
props: {
value: String,
name: String,
hydrate: {
type: Boolean,
default: true
}
},
setup({ name, value, hydrate }) {
if (!value) return () => null;
let tagName = hydrate ? "astro-slot" : "astro-static-slot";
return () => h(tagName, { name, innerHTML: value });
}
});
var static_html_default = StaticHtml;
async function check(Component) {
return !!Component["ssrRender"] || !!Component["__ssrInlineRender"];
}
async function renderToStaticMarkup(Component, inputProps, slotted, metadata) {
let prefix;
if (this && this.result) {
prefix = incrementId(this.result);
}
const attrs = { prefix };
const slots = {};
const props = { ...inputProps };
delete props.slot;
for (const [key, value] of Object.entries(slotted)) {
slots[key] = () => h(static_html_default, {
value,
name: key === "default" ? void 0 : key,
// Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
hydrate: metadata?.astroStaticSlot ? !!metadata.hydrate : true
});
}
const app = createSSRApp({ render: () => h(Component, props, slots) });
app.config.idPrefix = prefix;
await setup();
const html = await renderToString(app);
return { html, attrs };
}
const renderer = {
name: "@astrojs/vue",
check,
renderToStaticMarkup,
supportsAstroStaticSlot: true
};
var server_default = renderer;
const renderers = [Object.assign({"name":"@astrojs/vue","clientEntrypoint":"@astrojs/vue/client.js","serverEntrypoint":"@astrojs/vue/server.js"}, { ssr: server_default }),];
export { renderers };

18
grimoired.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,8 +0,0 @@
{
"hash": "867abd67",
"configHash": "8be89d65",
"lockfileHash": "e3b0c442",
"browserHash": "88ab680e",
"optimized": {},
"chunks": {}
}

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

18
public/grimoired.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,479 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500">Hook Configuration</label>
<p class="text-[11px] text-gray-600 mt-0.5">Define event handlers. This generates <code class="text-gray-500">hooks.json</code>.</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
@click="showRaw = !showRaw"
:class="['text-[11px] px-2 py-1 rounded-md border transition-all', showRaw ? 'border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)] bg-[var(--color-accent-500)]/10' : 'border-white/[0.06] text-gray-600 hover:text-gray-400']"
>JSON</button>
<button
type="button"
@click="addEntry"
class="inline-flex items-center gap-1 rounded-lg border border-white/[0.06] bg-white/[0.06] px-3 py-1.5 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] 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="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add event
</button>
</div>
</div>
<!-- Raw JSON toggle -->
<div v-if="showRaw" class="rounded-xl border border-white/[0.06] overflow-hidden">
<textarea
:value="outputJson"
@input="onRawInput"
rows="10"
spellcheck="false"
class="w-full bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-600 focus:outline-none resize-y leading-relaxed"
placeholder="{}"
/>
<p v-if="rawError" class="px-4 py-2 text-xs text-red-400 bg-red-500/5 border-t border-red-500/10">{{ rawError }}</p>
</div>
<!-- Visual editor -->
<template v-if="!showRaw">
<div v-if="entries.length === 0" class="rounded-xl border border-dashed border-white/[0.08] px-4 py-6 text-center">
<p class="text-xs text-gray-600">No event handlers configured. Click "Add event" to start.</p>
</div>
<div v-for="(entry, ei) in entries" :key="ei" class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-50)] overflow-hidden">
<!-- Event header -->
<div class="flex items-center gap-3 px-4 py-3 bg-white/[0.02] border-b border-white/[0.06]">
<svg class="h-4 w-4 text-violet-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
<select
v-model="entry.event"
class="flex-1 rounded-lg border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-1.5 text-sm text-white focus:outline-none transition-all"
>
<option v-for="e in EVENTS" :key="e.value" :value="e.value">{{ e.value }} {{ e.hint }}</option>
</select>
<button
type="button"
@click="removeEntry(ei)"
class="shrink-0 rounded p-1 text-gray-600 hover:text-red-400 hover:bg-red-400/10 transition-all"
title="Remove event"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Matcher -->
<div class="px-4 py-3 space-y-3">
<div class="relative">
<label class="block text-[11px] font-medium text-gray-500 mb-1">Matcher <span class="text-gray-600">(tool names joined with |)</span></label>
<div
class="flex flex-wrap items-center gap-1.5 rounded-lg border border-white/[0.06] bg-[var(--color-surface-100)] px-2.5 py-1.5 min-h-[36px] cursor-text focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all"
@click="focusMatcherInput(ei)"
>
<span
v-for="(tool, ti) in parseMatcherPills(entry.matcher)"
:key="ti"
class="inline-flex items-center gap-1 rounded-md bg-violet-500/15 border border-violet-500/25 pl-2 pr-1 py-0.5 text-xs font-mono font-medium text-violet-300"
>
{{ tool }}
<button type="button" @click.stop="removeMatcherTool(entry, ti)" class="rounded p-0.5 hover:bg-violet-500/30 transition-colors">
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</span>
<input
:ref="(el) => { matcherInputRefs[ei] = el as HTMLInputElement }"
:value="matcherQueries[ei] || ''"
@input="matcherQueries[ei] = ($event.target as HTMLInputElement).value"
@focus="matcherOpenIdx = ei"
@click.stop="matcherOpenIdx = ei"
@blur="onMatcherBlur"
@keydown="onMatcherKeydown($event, entry, ei)"
type="text"
:placeholder="parseMatcherPills(entry.matcher).length ? '' : 'Type tool name...'"
class="flex-1 min-w-[80px] bg-transparent text-xs font-mono text-white placeholder-gray-600 outline-none"
/>
</div>
<div
v-if="matcherOpenIdx === ei && matcherSuggestions(entry, ei).length > 0"
class="absolute z-20 mt-1 w-full max-h-40 overflow-auto rounded-lg border border-white/[0.08] bg-[var(--color-surface-200)] shadow-xl"
>
<button
v-for="s in matcherSuggestions(entry, ei)"
:key="s"
type="button"
@mousedown.prevent="addMatcherTool(entry, ei, s)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-xs font-mono text-gray-400 hover:bg-white/[0.06] hover:text-white transition-colors text-left"
>
{{ s }}
</button>
</div>
</div>
<!-- Handlers -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-[11px] font-medium text-gray-500">Handlers</label>
<button
type="button"
@click="addHandler(entry)"
class="text-[11px] text-gray-600 hover:text-[var(--color-accent-400)] transition-colors"
>+ Add handler</button>
</div>
<div v-for="(handler, hi) in entry.hooks" :key="hi" class="flex items-start gap-2 rounded-lg border border-white/[0.06] bg-[var(--color-surface-100)] p-2.5">
<select
v-model="handler.type"
class="shrink-0 rounded-md border border-white/[0.06] bg-[var(--color-surface-50)] px-2 py-1.5 text-xs text-white focus:outline-none transition-all"
>
<option value="command">command</option>
<option value="prompt">prompt</option>
<option value="agent">agent</option>
</select>
<input
v-if="handler.type === 'command'"
v-model="handler.command"
:ref="(el) => setHandlerRef(ei, hi, el as HTMLInputElement)"
@focus="lastFocusedHandler = { ei, hi }"
type="text"
placeholder="./scripts/lint.sh $CLAUDE_FILE_PATHS"
class="flex-1 rounded-md border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-1.5 text-xs font-mono text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none transition-all"
/>
<textarea
v-else-if="handler.type === 'prompt'"
v-model="handler.prompt"
:ref="(el) => setHandlerRef(ei, hi, el as HTMLTextAreaElement)"
@focus="lastFocusedHandler = { ei, hi }"
rows="2"
placeholder="Check that the code follows our style guide..."
class="flex-1 rounded-md border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-1.5 text-xs text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none resize-y transition-all"
/>
<input
v-else
v-model="handler.agent"
:ref="(el) => setHandlerRef(ei, hi, el as HTMLInputElement)"
@focus="lastFocusedHandler = { ei, hi }"
type="text"
placeholder="Agent name"
class="flex-1 rounded-md border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-1.5 text-xs font-mono text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none transition-all"
/>
<button
type="button"
@click="removeHandler(entry, hi)"
class="shrink-0 rounded p-1 text-gray-600 hover:text-red-400 hover:bg-red-400/10 transition-all"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
<div v-if="entry.hooks.length === 0" class="text-[11px] text-gray-600 pl-1">No handlers. Click "+ Add handler" above.</div>
</div>
</div>
<!-- ENV vars hint -->
<div v-if="ENV_VARS[entry.event]" class="px-4 py-2 bg-white/[0.01] border-t border-white/[0.04]">
<p class="text-[10px] text-gray-600">
Available vars:
<button
v-for="v in ENV_VARS[entry.event]"
:key="v"
type="button"
@click="insertVar(v)"
class="ml-1 font-mono text-gray-500 hover:text-violet-400 hover:bg-violet-500/10 rounded px-0.5 cursor-pointer transition-colors"
title="Click to insert at cursor"
>{{ v }}</button>
</p>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
interface Handler {
type: 'command' | 'prompt' | 'agent';
command: string;
prompt: string;
agent: string;
}
interface Entry {
event: string;
matcher: string;
hooks: Handler[];
}
const props = defineProps<{
modelValue: string;
tools?: string[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const EVENTS = [
{ value: 'PreToolUse', hint: 'before tool executes (can block)' },
{ value: 'PostToolUse', hint: 'after tool executes' },
{ value: 'Notification', hint: 'Claude sends a notification' },
{ value: 'Stop', hint: 'Claude finishes responding' },
{ value: 'SubagentStop', hint: 'subagent finishes' },
{ value: 'PreCompact', hint: 'before context compaction' },
{ value: 'PostCompact', hint: 'after context compaction' },
];
const ENV_VARS: Record<string, string[]> = {
PreToolUse: ['$CLAUDE_TOOL_NAME', '$CLAUDE_TOOL_INPUT'],
PostToolUse: ['$CLAUDE_TOOL_NAME', '$CLAUDE_TOOL_INPUT', '$CLAUDE_TOOL_OUTPUT', '$CLAUDE_FILE_PATHS'],
Notification: ['$CLAUDE_NOTIFICATION'],
Stop: ['$CLAUDE_STOP_HOOK_ACTIVE', '$CLAUDE_TRANSCRIPT'],
SubagentStop: ['$CLAUDE_STOP_HOOK_ACTIVE', '$CLAUDE_TRANSCRIPT'],
};
const showRaw = ref(false);
const rawError = ref('');
// --- Handler ref tracking for env var insertion ---
const handlerRefs = ref<Record<string, HTMLInputElement | HTMLTextAreaElement | null>>({});
const lastFocusedHandler = ref<{ ei: number; hi: number } | null>(null);
function setHandlerRef(ei: number, hi: number, el: HTMLInputElement | HTMLTextAreaElement | null) {
const key = `${ei}-${hi}`;
if (el) {
handlerRefs.value[key] = el;
} else {
delete handlerRefs.value[key];
}
}
function insertVar(varName: string) {
const focus = lastFocusedHandler.value;
if (!focus) return;
const key = `${focus.ei}-${focus.hi}`;
const el = handlerRefs.value[key];
if (!el) return;
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? start;
const before = el.value.slice(0, start);
const after = el.value.slice(end);
const newVal = before + varName + after;
// Update the reactive model
const handler = entries.value[focus.ei]?.hooks[focus.hi];
if (!handler) return;
if (handler.type === 'command') handler.command = newVal;
else if (handler.type === 'prompt') handler.prompt = newVal;
else handler.agent = newVal;
// Restore cursor position after Vue re-renders
const cursorPos = start + varName.length;
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(cursorPos, cursorPos);
});
}
// --- Matcher pill logic ---
const matcherInputRefs = ref<Record<number, HTMLInputElement | null>>({});
const matcherQueries = ref<Record<number, string>>({});
const matcherOpenIdx = ref<number | null>(null);
let matcherBlurTimer: ReturnType<typeof setTimeout>;
function parseMatcherPills(matcher: string): string[] {
if (!matcher) return [];
return matcher.split('|').map(s => s.trim()).filter(Boolean);
}
function buildMatcher(pills: string[]): string {
return pills.join('|');
}
function matcherSuggestions(entry: Entry, idx: number): string[] {
const q = (matcherQueries.value[idx] || '').toLowerCase().trim();
const current = new Set(parseMatcherPills(entry.matcher).map(s => s.toLowerCase()));
const toolList = props.tools || [];
const matches = toolList.filter(t => !current.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q)));
// If user typed something not in tools, offer it as custom
if (q && !current.has(q) && !matches.some(m => m.toLowerCase() === q)) {
matches.push(q);
}
return matches;
}
function addMatcherTool(entry: Entry, idx: number, tool: string) {
const pills = parseMatcherPills(entry.matcher);
if (!pills.some(p => p.toLowerCase() === tool.toLowerCase())) {
pills.push(tool);
entry.matcher = buildMatcher(pills);
}
matcherQueries.value[idx] = '';
matcherInputRefs.value[idx]?.focus();
}
function removeMatcherTool(entry: Entry, toolIdx: number) {
const pills = parseMatcherPills(entry.matcher);
pills.splice(toolIdx, 1);
entry.matcher = buildMatcher(pills);
}
function focusMatcherInput(idx: number) {
matcherInputRefs.value[idx]?.focus();
}
function onMatcherBlur() {
matcherBlurTimer = setTimeout(() => { matcherOpenIdx.value = null; }, 200);
}
function onMatcherKeydown(e: KeyboardEvent, entry: Entry, idx: number) {
const q = matcherQueries.value[idx] || '';
if (e.key === 'Enter') {
e.preventDefault();
const suggestions = matcherSuggestions(entry, idx);
if (suggestions.length > 0) {
addMatcherTool(entry, idx, suggestions[0]);
} else if (q.trim()) {
addMatcherTool(entry, idx, q.trim());
}
} else if (e.key === 'Backspace' && !q) {
const pills = parseMatcherPills(entry.matcher);
if (pills.length > 0) {
pills.pop();
entry.matcher = buildMatcher(pills);
}
}
}
function makeHandler(): Handler {
return { type: 'command', command: '', prompt: '', agent: '' };
}
function makeEntry(): Entry {
return { event: 'PreToolUse', matcher: '', hooks: [makeHandler()] };
}
// Parse initial value
function parseJson(json: string): Entry[] {
if (!json || json.trim() === '' || json.trim() === '{}') return [];
try {
const obj = JSON.parse(json);
const result: Entry[] = [];
for (const [event, matchers] of Object.entries(obj)) {
if (!Array.isArray(matchers)) continue;
for (const m of matchers as any[]) {
const hooks: Handler[] = (m.hooks || []).map((h: any) => ({
type: h.type || 'command',
command: h.command || '',
prompt: h.prompt || '',
agent: h.agent || '',
}));
result.push({ event, matcher: m.matcher || '', hooks });
}
}
return result;
} catch {
return [];
}
}
const entries = ref<Entry[]>(parseJson(props.modelValue));
// Build JSON from entries
function buildJson(): string {
const obj: Record<string, any[]> = {};
for (const entry of entries.value) {
if (!entry.event) continue;
const handlers = entry.hooks
.filter(h => {
if (h.type === 'command') return h.command.trim();
if (h.type === 'prompt') return h.prompt.trim();
if (h.type === 'agent') return h.agent.trim();
return false;
})
.map(h => {
if (h.type === 'command') return { type: 'command', command: h.command.trim() };
if (h.type === 'prompt') return { type: 'prompt', prompt: h.prompt.trim() };
return { type: 'agent', agent: h.agent.trim() };
});
if (handlers.length === 0 && !entry.matcher) continue;
if (!obj[entry.event]) obj[entry.event] = [];
obj[entry.event].push({
matcher: entry.matcher || '.*',
hooks: handlers,
});
}
if (Object.keys(obj).length === 0) return '';
return JSON.stringify(obj, null, 2);
}
const outputJson = computed(() => buildJson() || '{}');
// Emit changes
let skipWatch = false;
watch(entries, () => {
if (skipWatch) return;
emit('update:modelValue', buildJson());
}, { deep: true });
// Watch for external changes to modelValue
watch(() => props.modelValue, (val) => {
const newEntries = parseJson(val);
const currentJson = buildJson();
if (val !== currentJson) {
skipWatch = true;
entries.value = newEntries;
skipWatch = false;
}
});
function onRawInput(e: Event) {
const val = (e.target as HTMLTextAreaElement).value;
rawError.value = '';
try {
if (val.trim() === '' || val.trim() === '{}') {
skipWatch = true;
entries.value = [];
skipWatch = false;
emit('update:modelValue', '');
return;
}
JSON.parse(val);
skipWatch = true;
entries.value = parseJson(val);
skipWatch = false;
emit('update:modelValue', val.trim());
} catch {
rawError.value = 'Invalid JSON';
}
}
function addEntry() {
entries.value.push(makeEntry());
}
function removeEntry(idx: number) {
entries.value.splice(idx, 1);
}
function addHandler(entry: Entry) {
entry.hooks.push(makeHandler());
}
function removeHandler(entry: Entry, idx: number) {
entry.hooks.splice(idx, 1);
}
</script>

View File

@@ -0,0 +1,91 @@
---
interface HookHandler {
type: 'command' | 'prompt' | 'agent';
command?: string;
prompt?: string;
agent?: string;
}
interface HookMatcher {
matcher: string;
hooks: HookHandler[];
}
interface Props {
config: Record<string, HookMatcher[]>;
}
const { config } = Astro.props;
const EVENT_DESCRIPTIONS: Record<string, string> = {
PreToolUse: 'Before a tool executes (can block)',
PostToolUse: 'After a tool executes',
Notification: 'When Claude sends a notification',
Stop: 'When Claude finishes responding',
SubagentStop: 'When a subagent finishes',
PreCompact: 'Before context compaction',
PostCompact: 'After context compaction',
};
const HANDLER_ICONS: Record<string, string> = {
command: 'terminal',
prompt: 'chat',
agent: 'cpu',
};
const events = Object.entries(config).filter(([_, matchers]) => Array.isArray(matchers) && matchers.length > 0);
---
{events.length > 0 ? (
<div class="space-y-3">
<h3 class="text-xs font-medium uppercase tracking-wider text-gray-500">Hook Configuration</h3>
{events.map(([event, matchers]) => (
<div class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-50)] overflow-hidden">
<div class="flex items-center gap-2 px-4 py-2.5 bg-white/[0.02] border-b border-white/[0.06]">
<svg class="h-4 w-4 text-[var(--color-accent-500)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
<span class="text-sm font-medium text-white">{event}</span>
{EVENT_DESCRIPTIONS[event] && (
<span class="text-xs text-gray-600">— {EVENT_DESCRIPTIONS[event]}</span>
)}
</div>
<div class="divide-y divide-white/[0.04]">
{matchers.map((m) => (
<div class="px-4 py-3 space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Matcher:</span>
<code class="rounded bg-white/[0.06] px-2 py-0.5 text-xs font-mono text-[var(--color-accent-400)]">{m.matcher}</code>
</div>
{m.hooks.map((h) => (
<div class="flex items-start gap-2 ml-4">
{h.type === 'command' ? (
<svg class="h-3.5 w-3.5 mt-0.5 shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
) : h.type === 'prompt' ? (
<svg class="h-3.5 w-3.5 mt-0.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
</svg>
) : (
<svg class="h-3.5 w-3.5 mt-0.5 shrink-0 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
)}
<div class="min-w-0">
<span class="text-[10px] font-medium uppercase tracking-wider text-gray-600">{h.type}</span>
<code class="block text-xs font-mono text-gray-400 break-all">{h.command || h.prompt || h.agent || ''}</code>
</div>
</div>
))}
</div>
))}
</div>
</div>
))}
</div>
) : (
<div class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-50)] px-4 py-3">
<p class="text-xs text-gray-600">No hook configuration yet. Edit this hook to add event handlers.</p>
</div>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,23 +27,17 @@ function displayName(id: string): string {
.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);
const words = parts.map(p =>
/^\d+$/.test(p) ? p : p.charAt(0).toUpperCase() + p.slice(1)
);
// 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])) {
if (/^\d+$/.test(w) && result.length > 0 && /^\d+$/.test(result[result.length - 1])) {
result[result.length - 1] += '.' + w;
} else {
result.push(w!);
result.push(w);
}
}

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

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

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

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

View File

@@ -1,13 +1,26 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
/**
* Backward-compatible wrapper around resources.ts for skills.
* All new code should use resources.ts directly.
*/
import {
listResources,
getResource,
createResource,
updateResource,
deleteResource,
getAllTags as resourceGetAllTags,
getForksOf as resourceGetForksOf,
isValidSlug,
type ResourceMeta,
type Resource,
type ResourceFormat,
type ResourceFileEntry,
} from './resources';
import { getTypeConfig } from './registry';
export const SKILLS_DIR = path.resolve(
process.env.SKILLS_DIR || 'data/skills'
);
export { isValidSlug };
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SLUG_LENGTH = 64;
export const SKILLS_DIR = getTypeConfig('skills').dataDir;
export interface SkillMeta {
slug: string;
@@ -25,127 +38,81 @@ export interface SkillMeta {
'fork-of': string;
tags: string[];
hooks: Record<string, unknown> | null;
format: ResourceFormat;
}
export interface Skill extends SkillMeta {
content: string;
raw: string;
files: ResourceFileEntry[];
}
export function isValidSlug(slug: string): boolean {
return slug.length >= 2 && slug.length <= MAX_SLUG_LENGTH && SLUG_RE.test(slug);
}
function skillPath(slug: string): string {
return path.join(SKILLS_DIR, `${slug}.md`);
}
function parseTools(val: unknown): string[] {
function parseList(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
}
function parseSkill(slug: string, raw: string): Skill {
const { data, content } = matter(raw);
function toSkillMeta(r: ResourceMeta): SkillMeta {
const f = r.fields;
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) || '',
author: (data.author as string) || '',
'author-email': (data['author-email'] as string) || '',
'fork-of': (data['fork-of'] as string) || '',
tags: parseTools(data.tags),
hooks: (typeof data.hooks === 'object' && data.hooks !== null) ? data.hooks as Record<string, unknown> : null,
content: content.trim(),
raw,
slug: r.slug,
name: r.name,
description: r.description,
'allowed-tools': parseList(f['allowed-tools'] ?? f.allowedTools),
'argument-hint': (f['argument-hint'] as string) || '',
model: (f.model as string) || '',
'user-invocable': f['user-invocable'] !== false,
'disable-model-invocation': Boolean(f['disable-model-invocation']),
context: (f.context as string) || '',
agent: (f.agent as string) || '',
author: r.author,
'author-email': r['author-email'],
'fork-of': r['fork-of'],
tags: r.tags,
hooks: (typeof f.hooks === 'object' && f.hooks !== null) ? f.hooks as Record<string, unknown> : null,
format: r.format,
};
}
function toSkill(r: Resource): Skill {
return {
...toSkillMeta(r),
content: r.content,
raw: r.raw,
files: r.files,
};
}
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));
const resources = await listResources('skills');
return resources.map(toSkillMeta);
}
export async function getAllTags(): Promise<string[]> {
const all = await listSkills();
return [...new Set(all.flatMap(s => s.tags))].sort();
return resourceGetAllTags('skills');
}
export async function getForksOf(slug: string): Promise<SkillMeta[]> {
const all = await listSkills();
return all.filter(s => s['fork-of'] === slug);
const resources = await resourceGetForksOf('skills', slug);
return resources.map(toSkillMeta);
}
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;
}
const r = await getResource('skills', slug);
return r ? toSkill(r) : 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 createSkill(slug: string, content: string, format?: ResourceFormat): Promise<Skill> {
const r = await createResource('skills', slug, content, format);
return toSkill(r);
}
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);
const r = await updateResource('skills', slug, content);
return toSkill(r);
}
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);
return deleteResource('skills', slug);
}

View File

@@ -1,15 +1,19 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ResourceType } from './registry';
const STATS_FILE = path.resolve(process.env.STATS_FILE || 'data/stats.json');
export interface SkillStats {
export interface ResourceStats {
downloads: number;
pushes: number;
lastPushedAt: string | null;
}
type StatsStore = Record<string, SkillStats>;
/** @deprecated Use ResourceStats */
export type SkillStats = ResourceStats;
type StatsStore = Record<string, ResourceStats>;
async function readStore(): Promise<StatsStore> {
try {
@@ -28,32 +32,63 @@ async function writeStore(store: StatsStore): Promise<void> {
await fs.rename(tmp, STATS_FILE);
}
function ensure(store: StatsStore, slug: string): SkillStats {
if (!store[slug]) {
store[slug] = { downloads: 0, pushes: 0, lastPushedAt: null };
}
return store[slug];
/** Build the key used in stats.json. New format: "type:slug". Legacy keys (bare slug) are migrated lazily. */
function statsKey(type: ResourceType, slug: string): string {
return `${type}:${slug}`;
}
export async function recordDownload(slug: string): Promise<void> {
function ensure(store: StatsStore, key: string): ResourceStats {
if (!store[key]) {
store[key] = { downloads: 0, pushes: 0, lastPushedAt: null };
}
return store[key];
}
/** Lazy migration: if a bare slug key exists (legacy) and the namespaced key doesn't, migrate it. */
function migrateKey(store: StatsStore, type: ResourceType, slug: string): string {
const newKey = statsKey(type, slug);
if (!store[newKey] && store[slug] && type === 'skills') {
store[newKey] = store[slug];
delete store[slug];
}
return newKey;
}
export async function recordDownload(slug: string, type: ResourceType = 'skills'): Promise<void> {
const store = await readStore();
ensure(store, slug).downloads++;
const key = migrateKey(store, type, slug);
ensure(store, key).downloads++;
await writeStore(store);
}
export async function recordPush(slug: string): Promise<void> {
export async function recordPush(slug: string, type: ResourceType = 'skills'): Promise<void> {
const store = await readStore();
const entry = ensure(store, slug);
const key = migrateKey(store, type, slug);
const entry = ensure(store, key);
entry.pushes++;
entry.lastPushedAt = new Date().toISOString();
await writeStore(store);
}
export async function getStatsForSlug(slug: string): Promise<SkillStats> {
export async function getStatsForSlug(slug: string, type: ResourceType = 'skills'): Promise<ResourceStats> {
const store = await readStore();
return store[slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
const key = statsKey(type, slug);
// Check namespaced key first, fall back to legacy bare slug for skills
return store[key] || (type === 'skills' ? store[slug] : null) || { downloads: 0, pushes: 0, lastPushedAt: null };
}
export async function getAllStats(): Promise<StatsStore> {
return readStore();
export async function getAllStats(type?: ResourceType): Promise<Record<string, ResourceStats>> {
const store = await readStore();
if (!type) return store;
const prefix = `${type}:`;
const filtered: Record<string, ResourceStats> = {};
for (const [key, val] of Object.entries(store)) {
if (key.startsWith(prefix)) {
filtered[key.slice(prefix.length)] = val;
} else if (type === 'skills' && !key.includes(':')) {
// Legacy bare slug keys are skills
filtered[key] = val;
}
}
return filtered;
}

View File

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

View File

@@ -1,20 +1,26 @@
---
import Base from '../layouts/Base.astro';
import EditGate from '../components/EditGate.vue';
import DeleteButton from '../components/DeleteButton.vue';
import { getSkill, getForksOf } from '../lib/skills';
import { hasToken } from '../lib/tokens';
import { recordDownload, getStatsForSlug } from '../lib/stats';
import { marked } from 'marked';
/**
* Backward compatibility: /<slug> redirects to /skills/<slug> for browsers,
* serves raw markdown for CLI (curl/wget).
*/
import { getSkill } from '../lib/skills';
import { recordDownload } from '../lib/stats';
const { slug } = Astro.params;
// Skip if slug matches a known route prefix (type pages handle themselves)
const reserved = new Set(['skills', 'agents', 'output-styles', 'rules', 'new', 'api', 'i', 'gi', 'p', 'gp']);
if (reserved.has(slug!)) {
return new Response(null, { status: 404 });
}
const skill = await getSkill(slug!);
if (!skill) {
return Astro.redirect('/');
}
// curl / wget → raw markdown
// CLI (curl/wget) serve raw markdown directly (backward compat)
const accept = Astro.request.headers.get('accept') || '';
if (!accept.includes('text/html')) {
recordDownload(slug!);
@@ -23,165 +29,6 @@ if (!accept.includes('text/html')) {
});
}
const authorHasToken = skill['author-email'] ? await hasToken(skill['author-email']) : false;
const forks = await getForksOf(slug!);
const stats = await getStatsForSlug(slug!);
const html = await marked(skill.content);
const origin = Astro.url.origin;
const cmds = {
unix: `curl -fsSL ${origin}/${slug}/i | bash`,
unixGlobal: `curl -fsSL ${origin}/${slug}/gi | bash`,
win: `irm ${origin}/${slug}/i | iex`,
winGlobal: `irm ${origin}/${slug}/gi | iex`,
};
// Browser → redirect to new URL
return Astro.redirect(`/skills/${slug}`, 301);
---
<Base title={`${skill.name} — Skills Here`}>
<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>
<!-- Header + Install -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;">
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6" style="min-width: 0;">
<h1 class="text-2xl font-bold tracking-tight text-white mb-1">{skill.name}</h1>
{skill.description && <p class="text-gray-500 leading-relaxed mb-3">{skill.description}</p>}
{skill['allowed-tools'].length > 0 && (
<div class="flex flex-wrap gap-1.5">
{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>
)}
{skill.tags.length > 0 && (
<div class="flex flex-wrap gap-1.5 mt-3">
{skill.tags.map((tag) => (
<span class="rounded-full bg-[var(--color-accent-500)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]">
{tag}
</span>
))}
</div>
)}
{skill.author && (
<p class="text-xs text-gray-600 mt-3">by {skill.author}</p>
)}
{skill['fork-of'] && (
<p class="text-xs text-gray-600 mt-1">forked from <a href={`/${skill['fork-of']}`} class="text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">{skill['fork-of']}</a></p>
)}
{forks.length > 0 && (
<details class="mt-3">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300 transition-colors select-none">
{forks.length} fork{forks.length !== 1 ? 's' : ''}
</summary>
<ul class="mt-1.5 space-y-1 pl-3 border-l border-white/[0.06]">
{forks.map((f) => (
<li>
<a href={`/${f.slug}`} class="text-xs text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">
{f.name}
{f.author && <span class="text-gray-600"> by {f.author}</span>}
</a>
</li>
))}
</ul>
</details>
)}
<div class="flex flex-wrap items-center gap-4 mt-4 pt-3 border-t border-white/[0.06]">
<span class="inline-flex items-center gap-1.5 text-xs text-gray-500">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{stats.downloads} download{stats.downloads !== 1 ? 's' : ''}
</span>
<span class="inline-flex items-center gap-1.5 text-xs text-gray-500">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
{stats.pushes} push{stats.pushes !== 1 ? 'es' : ''}
</span>
{stats.lastPushedAt && (
<span class="text-xs text-gray-600">
Last updated {new Date(stats.lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
)}
</div>
</div>
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 space-y-4" style="min-width: 0;">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white">Install this skill</h2>
<div class="flex rounded-lg border border-white/[0.06] overflow-hidden" id="os-tabs">
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
<button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">Windows</button>
</div>
</div>
<p class="text-xs text-gray-500 leading-relaxed">Run in your project root to add this skill.</p>
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
<code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-500 select-all truncate">{cmds.unix}</code>
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-500 select-all truncate hidden">{cmds.win}</code>
<button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
<details class="group">
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">More options</summary>
<div class="mt-3 space-y-3 text-xs text-gray-500">
<div>
<p class="mb-1.5">Install globally (available in all projects):</p>
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
<code data-cmd="unix" class="flex-1 font-mono text-gray-500 select-all truncate">{cmds.unixGlobal}</code>
<code data-cmd="win" class="flex-1 font-mono text-gray-500 select-all truncate hidden">{cmds.winGlobal}</code>
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
</div>
</div>
</details>
</div>
</div>
<!-- Skill content -->
<div class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8">
<div class="absolute top-4 right-4 flex items-center gap-2">
<EditGate slug={slug!} authorEmail={skill['author-email']} authorName={skill.author} authorHasToken={authorHasToken} client:load />
<DeleteButton slug={slug!} authorEmail={skill['author-email']} authorName={skill.author} authorHasToken={authorHasToken} client:load />
</div>
<article class="skill-prose" set:html={html} />
</div>
</Base>
<style>
.os-tab { color: var(--color-gray-600); }
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
</style>
<script>
// OS detection + tab switching
const isWin = /Win/.test(navigator.platform);
function setOS(os: string) {
document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(el => {
el.classList.toggle('hidden', el.dataset.cmd !== os);
});
document.querySelectorAll<HTMLElement>('.os-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.os === os);
});
}
setOS(isWin ? 'win' : 'unix');
document.querySelectorAll<HTMLButtonElement>('.os-tab').forEach(tab => {
tab.addEventListener('click', () => setOS(tab.dataset.os!));
});
// Copy buttons
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const container = btn.parentElement!;
const visible = container.querySelector<HTMLElement>('[data-cmd]:not(.hidden)');
const code = visible?.textContent?.trim();
if (code) {
navigator.clipboard.writeText(code);
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
}
});
});
</script>

View File

@@ -1,53 +1,7 @@
---
import Base from '../../layouts/Base.astro';
import SkillEditor from '../../components/SkillEditor.vue';
import { getSkill, getAllTags } from '../../lib/skills';
import { getAvailableTools } from '../../lib/tools';
import { getAvailableModels } from '../../lib/models';
/**
* Backward compatibility: /<slug>/edit → /skills/<slug>/edit
*/
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) : '';
const availableTags = await getAllTags();
return Astro.redirect(`/skills/${slug}/edit`, 301);
---
<Base title={`Edit ${skill.name} — Skills Here`}>
<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}
initialAuthor={skill.author}
initialAuthorEmail={skill['author-email']}
initialTags={skill.tags.join(', ')}
:availableTools={availableTools}
:availableModels={availableModels}
availableTags={availableTags.join(',')}
client:load
/>
</Base>

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { getSkill } from '../../lib/skills';
import { isPowerShell } from '../../lib/sync';
/** Backward compat: /<slug>/gi — installs skill globally to ~/.claude/skills/ */
export const GET: APIRoute = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug!);
@@ -17,7 +18,7 @@ export const GET: APIRoute = async ({ params, url, request }) => {
'$ErrorActionPreference = "Stop"',
'$Dir = Join-Path $HOME ".claude\\skills"',
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Invoke-WebRequest -Uri "${origin}/skills/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} globally to $Dir\\${slug}.md"`,
'',
].join('\n')
@@ -25,7 +26,7 @@ export const GET: APIRoute = async ({ params, url, request }) => {
'#!/usr/bin/env bash',
'set -euo pipefail',
'mkdir -p ~/.claude/skills',
`curl -fsSL "${origin}/${slug}" -o ~/.claude/skills/${slug}.md`,
`curl -fsSL "${origin}/skills/${slug}" -o ~/.claude/skills/${slug}.md`,
`echo "✓ Installed ${skill.name} globally to ~/.claude/skills/${slug}.md"`,
'',
].join('\n');

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { getSkill } from '../../lib/skills';
import { isPowerShell } from '../../lib/sync';
/** Backward compat: /<slug>/i — installs skill to .claude/skills/ */
export const GET: APIRoute = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug!);
@@ -17,7 +18,7 @@ export const GET: APIRoute = async ({ params, url, request }) => {
'$ErrorActionPreference = "Stop"',
'$Dir = ".claude\\skills"',
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Invoke-WebRequest -Uri "${origin}/skills/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} to $Dir\\${slug}.md"`,
'',
].join('\n')
@@ -25,7 +26,7 @@ export const GET: APIRoute = async ({ params, url, request }) => {
'#!/usr/bin/env bash',
'set -euo pipefail',
'mkdir -p .claude/skills',
`curl -fsSL "${origin}/${slug}" -o ".claude/skills/${slug}.md"`,
`curl -fsSL "${origin}/skills/${slug}" -o ".claude/skills/${slug}.md"`,
`echo "✓ Installed ${skill.name} to .claude/skills/${slug}.md"`,
'',
].join('\n');

View File

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

View File

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

View File

@@ -0,0 +1,418 @@
import type { APIRoute } from 'astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource, getResourceFile } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
function parseList(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
}
export const GET: APIRoute = async ({ params, url, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response('Not found', { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const claudeDir = config.claudeDir;
// Check for preloaded skills (agents can reference skills)
const preloadedSlugs = parseList(resource.fields.skills);
const depSkills: Array<{ slug: string; name: string }> = [];
for (const skillSlug of preloadedSlugs) {
const skill = await getResource('skills', skillSlug);
if (skill) {
depSkills.push({ slug: skill.slug, name: skill.name });
}
}
const skillsClaudeDir = getTypeConfig('skills').claudeDir;
const files = resource.files;
// CLAUDE.md templates use section-delimited install (global → ~/.claude/CLAUDE.md)
if (type === 'claude-md') {
const script = ps
? buildClaudeMdPS(origin, slug!, resource.name, resource.author)
: buildClaudeMdBash(origin, slug!, resource.name, resource.author);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
// Hooks use a special install flow (global)
if (type === 'hooks') {
const hooksJsonBuf = await getResourceFile(resourceType, slug!, 'hooks.json');
const hooksJson = hooksJsonBuf ? hooksJsonBuf.toString('utf8').trim() : '{}';
const script = ps
? buildHooksPS(origin, slug!, resource.name, files, hooksJson)
: buildHooksBash(origin, slug!, resource.name, files, hooksJson);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
const script = ps
? buildPS(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir)
: buildBash(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
function buildBash(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`DEST=~/.claude/${claudeDir}/${slug}`,
'mkdir -p "$DEST"',
`curl -fsSL "${origin}/${type}/${slug}" -o "$DEST/${getMainFileName(type)}"`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$DEST/${dir}"`);
}
lines.push(`curl -fsSL "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
}
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
}
lines.push(`echo "✓ Installed ${name} globally to ~/.claude/${claudeDir}/${slug}/ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`mkdir -p ~/.claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ~/.claude/${skillsClaudeDir}/${s.slug}.md`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`mkdir -p ~/.claude/${claudeDir}`,
`curl -fsSL "${origin}/${type}/${slug}" -o ~/.claude/${claudeDir}/${slug}.md`,
`echo "✓ Installed ${name} globally to ~/.claude/${claudeDir}/${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`mkdir -p ~/.claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ~/.claude/${skillsClaudeDir}/${s.slug}.md`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function buildPS(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dest = Join-Path $HOME ".claude\\${claudeDir}\\${slug}"`,
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dest "${getMainFileName(type)}")`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
}
lines.push(`Write-Host "✓ Installed ${name} globally to $Dest\\ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`$SkillsDir = Join-Path $HOME ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dir = Join-Path $HOME ".claude\\${claudeDir}"`,
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${name} globally to $Dir\\${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`$SkillsDir = Join-Path $HOME ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function getMainFileName(type: string): string {
const map: Record<string, string> = {
skills: 'SKILL.md',
agents: 'AGENT.md',
'output-styles': 'OUTPUT-STYLE.md',
rules: 'RULE.md',
hooks: 'HOOK.md',
'claude-md': 'CLAUDE-MD.md',
};
return map[type] || `${type.toUpperCase()}.md`;
}
// --- CLAUDE.md template install scripts (global → ~/.claude/CLAUDE.md) ---
function buildClaudeMdBash(origin: string, slug: string, name: string, author: string): string {
const authorSuffix = author ? ` by ${author}` : '';
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SLUG="${slug}"`,
'TARGET="$HOME/.claude/CLAUDE.md"',
`SECTION_START="<!-- grimoired:$SLUG${authorSuffix} -->"`,
'SECTION_END="<!-- /grimoired:$SLUG -->"',
'GREP_PATTERN="<!-- grimoired:$SLUG "',
'',
'mkdir -p "$HOME/.claude"',
'',
'# Fetch raw content and strip frontmatter',
`CONTENT=$(curl -fsSL "${origin}/claude-md/${slug}")`,
'BODY=$(echo "$CONTENT" | awk \'BEGIN{skip=0} NR==1 && /^---$/{skip=1; next} skip && /^---$/{skip=0; next} !skip{print}\')',
'',
'# Build section block',
'BLOCK="$SECTION_START',
'$BODY',
'$SECTION_END"',
'',
'if [ -f "$TARGET" ]; then',
' if grep -qF "$GREP_PATTERN" "$TARGET"; then',
' awk -v prefix="$GREP_PATTERN" -v end="$SECTION_END" -v block="$BLOCK" \'',
' index($0, prefix) == 1 { skip=1; print block; next }',
' $0 == end { skip=0; next }',
' !skip { print }',
' \' "$TARGET" > "$TARGET.tmp" && mv "$TARGET.tmp" "$TARGET"',
' echo "✓ Updated section $SLUG globally in $TARGET"',
' else',
' echo "" >> "$TARGET"',
' echo "$BLOCK" >> "$TARGET"',
' echo "✓ Added section $SLUG globally to $TARGET"',
' fi',
'else',
' echo "$BLOCK" > "$TARGET"',
' echo "✓ Created $TARGET with section $SLUG"',
'fi',
'',
];
return lines.join('\n');
}
function buildClaudeMdPS(origin: string, slug: string, name: string, author: string): string {
const authorSuffix = author ? ` by ${author}` : '';
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$Slug = "${slug}"`,
'$Target = Join-Path $HOME ".claude\\CLAUDE.md"',
`$SectionStart = "<!-- grimoired:$Slug${authorSuffix} -->"`,
'$SectionEnd = "<!-- /grimoired:$Slug -->"',
'$GrepPattern = "<!-- grimoired:$Slug "',
'',
'New-Item -ItemType Directory -Force -Path (Join-Path $HOME ".claude") | Out-Null',
'',
`$Content = (Invoke-WebRequest -Uri "${origin}/claude-md/${slug}").Content`,
'$Lines = $Content -split "`n"',
'$InFrontmatter = $false',
'$BodyLines = @()',
'$FirstLine = $true',
'foreach ($line in $Lines) {',
' if ($FirstLine -and $line.Trim() -eq "---") { $InFrontmatter = $true; $FirstLine = $false; continue }',
' $FirstLine = $false',
' if ($InFrontmatter -and $line.Trim() -eq "---") { $InFrontmatter = $false; continue }',
' if (-not $InFrontmatter) { $BodyLines += $line }',
'}',
'$Body = $BodyLines -join "`n"',
'',
'$Block = "$SectionStart`n$Body`n$SectionEnd"',
'',
'if (Test-Path $Target) {',
' $Existing = Get-Content $Target -Raw',
' if ($Existing.Contains($GrepPattern)) {',
' $Pattern = "(?s)<!-- grimoired:" + [regex]::Escape($Slug) + " [^\\r\\n]*-->\\r?\\n?.*?" + [regex]::Escape($SectionEnd)',
' $Result = [regex]::Replace($Existing, $Pattern, $Block)',
' Set-Content -Path $Target -Value $Result -NoNewline',
' Write-Host "✓ Updated section $Slug globally in $Target"',
' } else {',
' Add-Content -Path $Target -Value "`n$Block"',
' Write-Host "✓ Added section $Slug globally to $Target"',
' }',
'} else {',
' Set-Content -Path $Target -Value $Block',
' Write-Host "✓ Created $Target with section $Slug"',
'}',
'',
];
return lines.join('\n');
}
// --- Hooks-specific install scripts (global) ---
function rewriteHookPaths(hooksJson: string, destDir: string): string {
return hooksJson
.replace(/"\.\/(scripts\/[^"]+)"/g, `"${destDir}/$1"`)
.replace(/"(scripts\/[^"]+)"/g, `"${destDir}/$1"`);
}
function buildHooksBash(
origin: string, slug: string, name: string,
files: Array<{ relativePath: string }>,
hooksJson: string,
): string {
const dest = `~/.claude/hooks/${slug}`;
const settings = '"$HOME/.claude/settings.json"';
const rewrittenJson = rewriteHookPaths(hooksJson, `$HOME/.claude/hooks/${slug}`);
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`DEST="${dest}"`,
`SETTINGS=${settings}`,
'',
'mkdir -p "$DEST"',
`curl -fsSL "${origin}/hooks/${slug}" -o "$DEST/HOOK.md"`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) lines.push(`mkdir -p "$DEST/${dir}"`);
lines.push(`curl -fsSL "${origin}/api/resources/hooks/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
}
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) lines.push(`chmod +x "$DEST/${f.relativePath}"`);
lines.push('');
lines.push('if [ ! -f "$SETTINGS" ]; then');
lines.push(' echo \'{}\' > "$SETTINGS"');
lines.push('fi');
lines.push('');
lines.push(`HOOK_JSON='${rewrittenJson.replace(/'/g, "'\\''")}'`);
lines.push('');
lines.push('if command -v jq &>/dev/null; then');
lines.push(' echo "$HOOK_JSON" > "$DEST/hooks.json"');
lines.push(' jq --slurpfile new "$DEST/hooks.json" \'');
lines.push(' .hooks //= {} |');
lines.push(' reduce ($new[0] | to_entries[]) as $e (.; .hooks[$e.key] = ((.hooks[$e.key] // []) + $e.value))');
lines.push(' \' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS"');
lines.push('else');
lines.push(' echo " ⚠ jq not found — hook config saved to $DEST/hooks.json"');
lines.push(' echo " Manually merge it into $SETTINGS under the \\"hooks\\" key."');
lines.push(' echo "$HOOK_JSON" > "$DEST/hooks.json"');
lines.push('fi');
lines.push('');
lines.push(`echo "✓ Installed hook ${name} globally"`);
lines.push(`echo " Files: $DEST/"`);
lines.push(`echo " Config: $SETTINGS"`);
lines.push('');
return lines.join('\n');
}
function buildHooksPS(
origin: string, slug: string, name: string,
files: Array<{ relativePath: string }>,
hooksJson: string,
): string {
const rewrittenJson = rewriteHookPaths(hooksJson, `$HOME/.claude/hooks/${slug}`);
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$Dest = Join-Path $HOME ".claude\\hooks\\${slug}"`,
'$Settings = Join-Path $HOME ".claude\\settings.json"',
'',
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
`Invoke-WebRequest -Uri "${origin}/hooks/${slug}" -OutFile (Join-Path $Dest "HOOK.md")`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/hooks/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
}
lines.push('');
lines.push('if (-not (Test-Path $Settings)) { Set-Content -Path $Settings -Value "{}" }');
lines.push('');
lines.push(`$hookJson = '${rewrittenJson.replace(/'/g, "''")}'`);
lines.push('Set-Content -Path (Join-Path $Dest "hooks.json") -Value $hookJson');
lines.push('');
lines.push('$settingsObj = Get-Content $Settings -Raw | ConvertFrom-Json');
lines.push('if (-not $settingsObj.hooks) { $settingsObj | Add-Member -NotePropertyName "hooks" -NotePropertyValue @{} }');
lines.push('$newHooks = $hookJson | ConvertFrom-Json');
lines.push('foreach ($prop in $newHooks.PSObject.Properties) {');
lines.push(' $event = $prop.Name');
lines.push(' $entries = @($prop.Value)');
lines.push(' if ($settingsObj.hooks.PSObject.Properties[$event]) {');
lines.push(' $settingsObj.hooks.$event = @($settingsObj.hooks.$event) + $entries');
lines.push(' } else {');
lines.push(' $settingsObj.hooks | Add-Member -NotePropertyName $event -NotePropertyValue $entries');
lines.push(' }');
lines.push('}');
lines.push('$settingsObj | ConvertTo-Json -Depth 10 | Set-Content $Settings');
lines.push('');
lines.push(`Write-Host "✓ Installed hook ${name} globally"`);
lines.push(`Write-Host " Files: $Dest\\"`);
lines.push(`Write-Host " Config: $Settings"`);
lines.push('');
return lines.join('\n');
}

View File

@@ -0,0 +1,469 @@
import type { APIRoute } from 'astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource, getResourceFile } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
function parseList(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
}
export const GET: APIRoute = async ({ params, url, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response('Not found', { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const claudeDir = config.claudeDir;
// Check for preloaded skills (agents can reference skills)
const preloadedSlugs = parseList(resource.fields.skills);
const depSkills: Array<{ slug: string; name: string }> = [];
for (const skillSlug of preloadedSlugs) {
const skill = await getResource('skills', skillSlug);
if (skill) {
depSkills.push({ slug: skill.slug, name: skill.name });
}
}
const skillsClaudeDir = getTypeConfig('skills').claudeDir;
const files = resource.files;
// CLAUDE.md templates use section-delimited install
if (type === 'claude-md') {
const script = ps
? buildClaudeMdPS(origin, slug!, resource.name, resource.author, false)
: buildClaudeMdBash(origin, slug!, resource.name, resource.author, false);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
// Hooks use a special install flow
if (type === 'hooks') {
const hooksJsonBuf = await getResourceFile(resourceType, slug!, 'hooks.json');
const hooksJson = hooksJsonBuf ? hooksJsonBuf.toString('utf8').trim() : '{}';
const script = ps
? buildHooksPS(origin, slug!, resource.name, files, hooksJson, false)
: buildHooksBash(origin, slug!, resource.name, files, hooksJson, false);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
const script = ps
? buildPS(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir)
: buildBash(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
function buildBash(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`DEST=".claude/${claudeDir}/${slug}"`,
'mkdir -p "$DEST"',
`curl -fsSL "${origin}/${type}/${slug}" -o "$DEST/${getMainFileName(type)}"`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$DEST/${dir}"`);
}
lines.push(`curl -fsSL "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
}
// chmod +x for scripts
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
}
lines.push(`echo "✓ Installed ${name} to .claude/${claudeDir}/${slug}/ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`mkdir -p .claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ".claude/${skillsClaudeDir}/${s.slug}.md"`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`mkdir -p .claude/${claudeDir}`,
`curl -fsSL "${origin}/${type}/${slug}" -o ".claude/${claudeDir}/${slug}.md"`,
`echo "✓ Installed ${name} to .claude/${claudeDir}/${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`mkdir -p .claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ".claude/${skillsClaudeDir}/${s.slug}.md"`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function buildPS(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dest = ".claude\\${claudeDir}\\${slug}"`,
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dest "${getMainFileName(type)}")`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
}
lines.push(`Write-Host "✓ Installed ${name} to .claude\\${claudeDir}\\${slug}\\ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`$SkillsDir = ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dir = ".claude\\${claudeDir}"`,
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${name} to $Dir\\${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`$SkillsDir = ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function getMainFileName(type: string): string {
const map: Record<string, string> = {
skills: 'SKILL.md',
agents: 'AGENT.md',
'output-styles': 'OUTPUT-STYLE.md',
rules: 'RULE.md',
hooks: 'HOOK.md',
'claude-md': 'CLAUDE-MD.md',
};
return map[type] || `${type.toUpperCase()}.md`;
}
// --- CLAUDE.md template install scripts ---
function buildClaudeMdBash(origin: string, slug: string, name: string, author: string, global: boolean): string {
const target = global ? '"$HOME/.claude/CLAUDE.md"' : '"CLAUDE.md"';
const mkdirLine = global ? 'mkdir -p "$HOME/.claude"' : '';
const label = global ? 'globally ' : '';
const authorSuffix = author ? ` by ${author}` : '';
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SLUG="${slug}"`,
`TARGET=${target}`,
`SECTION_START="<!-- grimoired:$SLUG${authorSuffix} -->"`,
`SECTION_END="<!-- /grimoired:$SLUG -->"`,
`GREP_PATTERN="<!-- grimoired:$SLUG "`,
'',
];
if (mkdirLine) lines.push(mkdirLine, '');
lines.push(
'# Fetch raw content and strip frontmatter',
`CONTENT=$(curl -fsSL "${origin}/claude-md/${slug}")`,
'BODY=$(echo "$CONTENT" | awk \'BEGIN{skip=0} NR==1 && /^---$/{skip=1; next} skip && /^---$/{skip=0; next} !skip{print}\')',
'',
'# Build section block',
'BLOCK="$SECTION_START',
'$BODY',
'$SECTION_END"',
'',
'if [ -f "$TARGET" ]; then',
' if grep -qF "$GREP_PATTERN" "$TARGET"; then',
' # Replace existing section (match by slug prefix, author may have changed)',
' awk -v prefix="$GREP_PATTERN" -v end="$SECTION_END" -v block="$BLOCK" \'',
' index($0, prefix) == 1 { skip=1; print block; next }',
' $0 == end { skip=0; next }',
' !skip { print }',
' \' "$TARGET" > "$TARGET.tmp" && mv "$TARGET.tmp" "$TARGET"',
` echo "✓ Updated ${label}section $SLUG in $TARGET"`,
' else',
' # Append section',
' echo "" >> "$TARGET"',
' echo "$BLOCK" >> "$TARGET"',
` echo "✓ Added ${label}section $SLUG to $TARGET"`,
' fi',
'else',
' echo "$BLOCK" > "$TARGET"',
` echo "✓ Created $TARGET with section $SLUG"`,
'fi',
'',
);
return lines.join('\n');
}
function buildClaudeMdPS(origin: string, slug: string, name: string, author: string, global: boolean): string {
const target = global
? 'Join-Path $HOME ".claude\\CLAUDE.md"'
: '"CLAUDE.md"';
const mkdirLine = global ? 'New-Item -ItemType Directory -Force -Path (Join-Path $HOME ".claude") | Out-Null' : '';
const label = global ? 'globally ' : '';
const authorSuffix = author ? ` by ${author}` : '';
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$Slug = "${slug}"`,
`$Target = ${target}`,
`$SectionStart = "<!-- grimoired:$Slug${authorSuffix} -->"`,
'$SectionEnd = "<!-- /grimoired:$Slug -->"',
'$GrepPattern = "<!-- grimoired:$Slug "',
'',
];
if (mkdirLine) lines.push(mkdirLine, '');
lines.push(
'# Fetch raw content and strip frontmatter',
`$Content = (Invoke-WebRequest -Uri "${origin}/claude-md/${slug}").Content`,
'$Lines = $Content -split "`n"',
'$InFrontmatter = $false',
'$BodyLines = @()',
'$FirstLine = $true',
'foreach ($line in $Lines) {',
' if ($FirstLine -and $line.Trim() -eq "---") { $InFrontmatter = $true; $FirstLine = $false; continue }',
' $FirstLine = $false',
' if ($InFrontmatter -and $line.Trim() -eq "---") { $InFrontmatter = $false; continue }',
' if (-not $InFrontmatter) { $BodyLines += $line }',
'}',
'$Body = $BodyLines -join "`n"',
'',
'$Block = "$SectionStart`n$Body`n$SectionEnd"',
'',
'if (Test-Path $Target) {',
' $Existing = Get-Content $Target -Raw',
' if ($Existing.Contains($GrepPattern)) {',
' # Replace existing section (match by slug prefix, author may have changed)',
' $Pattern = "(?s)<!-- grimoired:" + [regex]::Escape($Slug) + " [^\\r\\n]*-->\\r?\\n?.*?" + [regex]::Escape($SectionEnd)',
' $Result = [regex]::Replace($Existing, $Pattern, $Block)',
' Set-Content -Path $Target -Value $Result -NoNewline',
` Write-Host "✓ Updated ${label}section $Slug in $Target"`,
' } else {',
' Add-Content -Path $Target -Value "`n$Block"',
` Write-Host "✓ Added ${label}section $Slug to $Target"`,
' }',
'} else {',
' Set-Content -Path $Target -Value $Block',
` Write-Host "✓ Created $Target with section $Slug"`,
'}',
'',
);
return lines.join('\n');
}
// --- Hooks-specific install scripts ---
function buildHooksBash(
origin: string, slug: string, name: string,
files: Array<{ relativePath: string }>,
hooksJson: string, global: boolean,
): string {
const prefix = global ? '~/' : '';
const dest = `${prefix}.claude/hooks/${slug}`;
const settings = global
? '"$HOME/.claude/settings.json"'
: '".claude/settings.local.json"';
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`DEST="${dest}"`,
`SETTINGS=${settings}`,
'',
'# Download hook files',
'mkdir -p "$DEST"',
`curl -fsSL "${origin}/hooks/${slug}" -o "$DEST/HOOK.md"`,
];
// Download sub-files (scripts, references, assets)
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$DEST/${dir}"`);
}
lines.push(`curl -fsSL "${origin}/api/resources/hooks/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
}
// chmod +x for scripts
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
}
// Rewrite paths in hooks.json and merge into settings
// We embed the hooks config as a heredoc, rewriting script paths
const rewrittenJson = rewriteHookPaths(hooksJson, dest);
lines.push('');
lines.push('# Merge hook config into settings');
lines.push('if [ ! -f "$SETTINGS" ]; then');
lines.push(' echo \'{}\' > "$SETTINGS"');
lines.push('fi');
lines.push('');
lines.push('# Write hook config');
lines.push(`HOOK_JSON='${rewrittenJson.replace(/'/g, "'\\''")}'`);
lines.push('');
lines.push('# Check for jq');
lines.push('if command -v jq &>/dev/null; then');
lines.push(' echo "$HOOK_JSON" > "$DEST/hooks.json"');
lines.push(' jq --slurpfile new "$DEST/hooks.json" \'');
lines.push(' .hooks //= {} |');
lines.push(' reduce ($new[0] | to_entries[]) as $e (.; .hooks[$e.key] = ((.hooks[$e.key] // []) + $e.value))');
lines.push(' \' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS"');
lines.push('else');
lines.push(' echo " ⚠ jq not found — hook config saved to $DEST/hooks.json"');
lines.push(' echo " Manually merge it into $SETTINGS under the \\"hooks\\" key."');
lines.push(' echo "$HOOK_JSON" > "$DEST/hooks.json"');
lines.push('fi');
lines.push('');
lines.push(`echo "✓ Installed hook ${name}"`);
lines.push(`echo " Files: $DEST/"`);
lines.push(`echo " Config: $SETTINGS"`);
lines.push('');
return lines.join('\n');
}
function buildHooksPS(
origin: string, slug: string, name: string,
files: Array<{ relativePath: string }>,
hooksJson: string, global: boolean,
): string {
const dest = global
? `Join-Path $HOME ".claude\\hooks\\${slug}"`
: `".claude\\hooks\\${slug}"`;
const settings = global
? 'Join-Path $HOME ".claude\\settings.json"'
: '".claude\\settings.local.json"';
const rewrittenJson = rewriteHookPaths(hooksJson, global ? `$HOME/.claude/hooks/${slug}` : `.claude/hooks/${slug}`);
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$Dest = ${dest}`,
`$Settings = ${settings}`,
'',
'# Download hook files',
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
`Invoke-WebRequest -Uri "${origin}/hooks/${slug}" -OutFile (Join-Path $Dest "HOOK.md")`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/hooks/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
}
lines.push('');
lines.push('# Merge hook config into settings');
lines.push('if (-not (Test-Path $Settings)) {');
lines.push(' Set-Content -Path $Settings -Value "{}"');
lines.push('}');
lines.push('');
lines.push(`$hookJson = '${rewrittenJson.replace(/'/g, "''")}'`);
lines.push('Set-Content -Path (Join-Path $Dest "hooks.json") -Value $hookJson');
lines.push('');
lines.push('$settingsObj = Get-Content $Settings -Raw | ConvertFrom-Json');
lines.push('if (-not $settingsObj.hooks) { $settingsObj | Add-Member -NotePropertyName "hooks" -NotePropertyValue @{} }');
lines.push('$newHooks = $hookJson | ConvertFrom-Json');
lines.push('foreach ($prop in $newHooks.PSObject.Properties) {');
lines.push(' $event = $prop.Name');
lines.push(' $entries = @($prop.Value)');
lines.push(' if ($settingsObj.hooks.PSObject.Properties[$event]) {');
lines.push(' $settingsObj.hooks.$event = @($settingsObj.hooks.$event) + $entries');
lines.push(' } else {');
lines.push(' $settingsObj.hooks | Add-Member -NotePropertyName $event -NotePropertyValue $entries');
lines.push(' }');
lines.push('}');
lines.push('$settingsObj | ConvertTo-Json -Depth 10 | Set-Content $Settings');
lines.push('');
lines.push(`Write-Host "✓ Installed hook ${name}"`);
lines.push(`Write-Host " Files: $Dest\\"`);
lines.push(`Write-Host " Config: $Settings"`);
lines.push('');
return lines.join('\n');
}
function rewriteHookPaths(hooksJson: string, destDir: string): string {
// Rewrite relative script paths to point to the install destination
// e.g. "./scripts/lint.sh" → ".claude/hooks/my-hook/scripts/lint.sh"
// Also handle "scripts/" without "./"
return hooksJson
.replace(/"\.\/(scripts\/[^"]+)"/g, `"${destDir}/$1"`)
.replace(/"(scripts\/[^"]+)"/g, `"${destDir}/$1"`);
}

View File

@@ -0,0 +1,219 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../lib/registry';
import { getResource } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
export const GET: APIRoute = async ({ params, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type) || (type !== 'hooks' && type !== 'claude-md')) {
return new Response('Not found', { status: 404 });
}
const resource = await getResource(type, slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
const ps = isPowerShell(request);
if (type === 'claude-md') {
const script = ps
? buildClaudeMdPS(slug!, resource.name)
: buildClaudeMdBash(slug!, resource.name);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
const script = ps
? buildPS(slug!, resource.name)
: buildBash(slug!, resource.name);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
function buildBash(slug: string, name: string): string {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SLUG="${slug}"`,
'HOOK_DIR=".claude/hooks/$SLUG"',
'SETTINGS=".claude/settings.local.json"',
'',
'# Also check global location',
'GLOBAL_HOOK_DIR="$HOME/.claude/hooks/$SLUG"',
'GLOBAL_SETTINGS="$HOME/.claude/settings.json"',
'',
'remove_hook() {',
' local hook_dir="$1"',
' local settings="$2"',
' local label="$3"',
'',
' if [ -d "$hook_dir" ]; then',
' rm -rf "$hook_dir"',
' echo " ✓ Removed $hook_dir"',
' fi',
'',
' if [ -f "$settings" ] && command -v jq &>/dev/null; then',
' # Read hooks.json to know which events to clean',
' local hooks_file="$hook_dir/hooks.json"',
' # Since we already removed the dir, try to clean by matching paths',
` jq --arg slug "$SLUG" '`,
' if .hooks then',
' .hooks |= with_entries(',
' .value |= map(select(',
' .hooks | all(',
' (.command // "") | test("hooks/" + $slug + "/") | not',
' )',
' )) | select(length > 0)',
' )',
' else . end',
` ' "$settings" > "$settings.tmp" && mv "$settings.tmp" "$settings"`,
' echo " ✓ Cleaned $settings"',
' fi',
'}',
'',
'echo "Uninstalling hook: ' + name + '"',
'',
'# Remove local install',
'if [ -d "$HOOK_DIR" ] || [ -f "$SETTINGS" ]; then',
' remove_hook "$HOOK_DIR" "$SETTINGS" "local"',
'fi',
'',
'# Remove global install',
'if [ -d "$GLOBAL_HOOK_DIR" ] || [ -f "$GLOBAL_SETTINGS" ]; then',
' remove_hook "$GLOBAL_HOOK_DIR" "$GLOBAL_SETTINGS" "global"',
'fi',
'',
`echo "✓ Uninstalled hook ${name}"`,
'',
];
return lines.join('\n');
}
function buildPS(slug: string, name: string): string {
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$Slug = "${slug}"`,
'$HookDir = ".claude\\hooks\\$Slug"',
'$Settings = ".claude\\settings.local.json"',
'$GlobalHookDir = Join-Path $HOME ".claude\\hooks\\$Slug"',
'$GlobalSettings = Join-Path $HOME ".claude\\settings.json"',
'',
'function Remove-Hook($hookDir, $settingsPath) {',
' if (Test-Path $hookDir) {',
' Remove-Item -Recurse -Force $hookDir',
' Write-Host " ✓ Removed $hookDir"',
' }',
' if (Test-Path $settingsPath) {',
' $obj = Get-Content $settingsPath -Raw | ConvertFrom-Json',
' if ($obj.hooks) {',
' $cleaned = @{}',
' foreach ($prop in $obj.hooks.PSObject.Properties) {',
' $filtered = @($prop.Value | Where-Object {',
' $dominated = $false',
' foreach ($h in $_.hooks) {',
' if ($h.command -and $h.command -match "hooks/$Slug/") { $dominated = $true }',
' }',
' -not $dominated',
' })',
' if ($filtered.Count -gt 0) { $cleaned[$prop.Name] = $filtered }',
' }',
' $obj.hooks = $cleaned',
' $obj | ConvertTo-Json -Depth 10 | Set-Content $settingsPath',
' Write-Host " ✓ Cleaned $settingsPath"',
' }',
' }',
'}',
'',
`Write-Host "Uninstalling hook: ${name}"`,
'',
'if ((Test-Path $HookDir) -or (Test-Path $Settings)) {',
' Remove-Hook $HookDir $Settings',
'}',
'if ((Test-Path $GlobalHookDir) -or (Test-Path $GlobalSettings)) {',
' Remove-Hook $GlobalHookDir $GlobalSettings',
'}',
'',
`Write-Host "✓ Uninstalled hook ${name}"`,
'',
];
return lines.join('\n');
}
// --- CLAUDE.md template uninstall scripts ---
function buildClaudeMdBash(slug: string, name: string): string {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SLUG="${slug}"`,
'SECTION_END="<!-- /grimoired:$SLUG -->"',
'GREP_PATTERN="<!-- grimoired:$SLUG "',
'',
`echo "Uninstalling CLAUDE.md template: ${name}"`,
'',
'for TARGET in "CLAUDE.md" "$HOME/.claude/CLAUDE.md"; do',
' if [ -f "$TARGET" ] && grep -qF "$GREP_PATTERN" "$TARGET"; then',
' awk -v prefix="$GREP_PATTERN" -v end="$SECTION_END" \'',
' index($0, prefix) == 1 { skip=1; next }',
' $0 == end { skip=0; next }',
' !skip { print }',
' \' "$TARGET" > "$TARGET.tmp" && mv "$TARGET.tmp" "$TARGET"',
' # Remove trailing blank lines',
' if [ -s "$TARGET" ]; then',
' sed -i.bak -e :a -e \'/^\\n*$/{$d;N;ba\' -e \'}\' "$TARGET" && rm -f "$TARGET.bak"',
' else',
' rm -f "$TARGET"',
' echo " ✓ Removed empty $TARGET"',
' continue',
' fi',
' echo " ✓ Removed section $SLUG from $TARGET"',
' fi',
'done',
'',
`echo "✓ Uninstalled CLAUDE.md template ${name}"`,
'',
];
return lines.join('\n');
}
function buildClaudeMdPS(slug: string, name: string): string {
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$Slug = "${slug}"`,
'$SectionEnd = "<!-- /grimoired:$Slug -->"',
'$GrepPattern = "<!-- grimoired:$Slug "',
'',
`Write-Host "Uninstalling CLAUDE.md template: ${name}"`,
'',
'$Targets = @("CLAUDE.md", (Join-Path $HOME ".claude\\CLAUDE.md"))',
'foreach ($Target in $Targets) {',
' if (Test-Path $Target) {',
' $Content = Get-Content $Target -Raw',
' if ($Content.Contains($GrepPattern)) {',
' $Pattern = "(?s)<!-- grimoired:" + [regex]::Escape($Slug) + " [^\\r\\n]*-->\\r?\\n?.*?" + [regex]::Escape($SectionEnd) + "\\r?\\n?"',
' $Result = [regex]::Replace($Content, $Pattern, "").TrimEnd()',
' if ($Result.Length -eq 0) {',
' Remove-Item $Target',
' Write-Host " ✓ Removed empty $Target"',
' } else {',
' Set-Content -Path $Target -Value $Result -NoNewline',
' Write-Host " ✓ Removed section $Slug from $Target"',
' }',
' }',
' }',
'}',
'',
`Write-Host "✓ Uninstalled CLAUDE.md template ${name}"`,
'',
];
return lines.join('\n');
}

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More