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.
This commit is contained in:
Alejandro Martinez
2026-02-16 11:51:33 +01:00
parent c1a9442868
commit b86c9f3e3a
33 changed files with 2345 additions and 204 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

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

@@ -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"
}
}

21
skills-here.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

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

@@ -116,8 +116,8 @@
<p class="mt-1.5 text-xs text-gray-600">Type and press Enter or comma. Click suggestions to add.</p>
</div>
<!-- Format toggle (create mode only) -->
<div v-if="mode === 'create' && !isFork">
<!-- 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
@@ -136,12 +136,15 @@
</p>
</div>
<!-- Folder files manager (create mode, folder selected) -->
<div v-if="mode === 'create' && !isFork && format === 'folder'" class="rounded-xl border border-white/[0.08] bg-[var(--color-surface-50)] p-4 space-y-4">
<!-- 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">Folder files</p>
<p class="text-[11px] text-gray-600 mt-0.5"><code class="text-gray-500">{{ computedSlug }}/{{ mainFileName }}</code> is generated from the body. Add scripts, docs, or assets here.</p>
<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
@@ -176,7 +179,7 @@
<!-- 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>
<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>
@@ -189,20 +192,21 @@
<input
v-model="draftFileName"
type="text"
placeholder="run.sh"
: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 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-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>
<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>
@@ -260,7 +264,7 @@
</div>
</div>
</div>
<p v-else class="text-xs text-gray-600">No extra files yet. Create scripts, docs, or upload assets.</p>
<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) -->
@@ -275,6 +279,10 @@
<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"
@@ -286,6 +294,122 @@
/>
</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
@@ -297,11 +421,11 @@
/>
</div>
<!-- Body + Preview -->
<div class="grid gap-4 lg:grid-cols-2">
<!-- 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>Body</span>
<span>{{ isClaudeMdType ? 'Template Content' : 'Body' }}</span>
<span :class="bodyLines > 400 ? 'text-amber-500' : ''">{{ bodyLines }}/500 lines</span>
</label>
<textarea
@@ -352,6 +476,7 @@ 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;
@@ -387,13 +512,24 @@ const props = defineProps<{
}>();
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 format = ref<'file' | 'folder'>(props.initialFormat || 'file');
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 {
@@ -573,6 +709,8 @@ const mainFileName = computed(() => {
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';
});
@@ -583,6 +721,8 @@ const bodyPlaceholder = computed(() => {
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...';
});
@@ -633,8 +773,9 @@ function buildContent(): string {
if (tags.value.length > 0) lines.push(`tags: ${tags.value.join(', ')}`);
// Type-specific fields
// 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') {
@@ -671,6 +812,20 @@ function buildContent(): string {
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 = '';
@@ -696,6 +851,11 @@ async function save() {
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`;
@@ -721,6 +881,12 @@ async function save() {
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) {

View File

@@ -209,6 +209,8 @@ const typeTabs = computed(() => [
{ 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('');

View File

@@ -62,6 +62,14 @@ const { title = 'Grimoired' } = Astro.props;
<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>

View File

@@ -1,6 +1,6 @@
import path from 'node:path';
export const RESOURCE_TYPES = ['skills', 'agents', 'output-styles', 'rules'] as const;
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 {
@@ -229,6 +229,46 @@ export const REGISTRY: Record<ResourceType, ResourceTypeConfig> = {
},
],
},
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 {

View File

@@ -138,11 +138,27 @@ async function collectFiles(dirPath: string): Promise<ResourceFileEntry[]> {
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.includes('..') || path.isAbsolute(relativePath)) {
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(', ')}`);
}
@@ -219,8 +235,8 @@ export async function getResource(type: ResourceType, slug: string): Promise<Res
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
const config = getTypeConfig(type);
// Exclude the main file from the files list
files = allFiles.filter(f => f.relativePath !== config.mainFileName);
// 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);
}
@@ -249,6 +265,10 @@ export async function createResource(
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');
}
@@ -270,11 +290,12 @@ export async function updateResource(type: ResourceType, slug: string, content:
if (resolved.format === 'folder') {
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
files = allFiles.filter(f => f.relativePath !== config.mainFileName);
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);
@@ -299,7 +320,7 @@ export async function listResourceFiles(type: ResourceType, slug: string): Promi
}
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
return allFiles.filter(f => f.relativePath !== config.mainFileName);
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> {

View File

@@ -7,6 +7,26 @@ export function isPowerShell(request: Request): boolean {
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> {
@@ -277,32 +297,37 @@ export async function buildSyncScriptForType(baseUrl: string, targetDir: string,
lines.push('');
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/${r.slug}"`);
lines.push(`curl -fsSL "${baseUrl}/${type}/${r.slug}" -o "$TARGET_DIR/${r.slug}/${config.mainFileName}"`);
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 dir = f.relativePath.split('/').slice(0, -1).join('/');
const safePath = assertSafePath(f.relativePath);
const dir = safePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}/${dir}"`);
lines.push(`mkdir -p "$TARGET_DIR/${safeSlug}/${dir}"`);
}
lines.push(`curl -fsSL "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -o "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
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) {
lines.push(`chmod +x "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
const safePath = assertSafePath(f.relativePath);
lines.push(`chmod +x "$TARGET_DIR/${safeSlug}/${safePath}"`);
}
lines.push(`echo " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
lines.push(`echo " ✓ ${safeName} (folder, ${full.files.length + 1} files)"`);
} else {
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
lines.push(`curl -fsSL "${resourceUrl}" -o "$TARGET_DIR/${r.slug}.md"`);
lines.push(`echo " ✓ ${r.name}"`);
const resourceUrl = `${baseUrl}/${type}/${safeSlug}`;
lines.push(`curl -fsSL "${resourceUrl}" -o "$TARGET_DIR/${safeSlug}.md"`);
lines.push(`echo " ✓ ${safeName}"`);
}
}
@@ -339,27 +364,31 @@ export async function buildSyncScriptPSForType(baseUrl: string, targetDir: strin
lines.push('');
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 "${r.slug}") | Out-Null`);
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/${type}/${r.slug}" -OutFile (Join-Path $TargetDir "${r.slug}\\${config.mainFileName}")`);
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 dir = f.relativePath.split('/').slice(0, -1).join('\\');
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 "${r.slug}\\${dir}") | Out-Null`);
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${safeSlug}\\${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -OutFile (Join-Path $TargetDir "${r.slug}\\${winPath}")`);
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 " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
lines.push(`Write-Host " ✓ ${safeName} (folder, ${full.files.length + 1} files)"`);
} else {
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
lines.push(`Invoke-WebRequest -Uri "${resourceUrl}" -OutFile (Join-Path $TargetDir "${r.slug}.md")`);
lines.push(`Write-Host " ✓ ${r.name}"`);
const resourceUrl = `${baseUrl}/${type}/${safeSlug}`;
lines.push(`Invoke-WebRequest -Uri "${resourceUrl}" -OutFile (Join-Path $TargetDir "${safeSlug}.md")`);
lines.push(`Write-Host " ✓ ${safeName}"`);
}
}

View File

@@ -3,8 +3,9 @@ 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, getForksOf } from '../../lib/resources';
import { getResource, getResourceFile, getForksOf } from '../../lib/resources';
import { hasToken } from '../../lib/tokens';
import { recordDownload, getStatsForSlug } from '../../lib/stats';
import { marked } from 'marked';
@@ -32,6 +33,15 @@ if (!accept.includes('text/html')) {
});
}
// 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);
@@ -42,6 +52,8 @@ const cmds = {
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
@@ -141,7 +153,7 @@ const allowedTools = Array.isArray(fields['allowed-tools'] ?? fields.allowedTool
<button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">Windows</button>
</div>
</div>
<p class="text-xs text-gray-500 leading-relaxed">Run in your project root to add this {config.labelSingular.toLowerCase()}.</p>
<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>
@@ -158,6 +170,16 @@ const allowedTools = Array.isArray(fields['allowed-tools'] ?? fields.allowedTool
<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>
@@ -183,15 +205,29 @@ const allowedTools = Array.isArray(fields['allowed-tools'] ?? fields.allowedTool
<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="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 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>
<article class="skill-prose" set:html={html} />
</div>
)}
</Base>

View File

@@ -2,7 +2,7 @@
import Base from '../../../layouts/Base.astro';
import ResourceEditor from '../../../components/ResourceEditor.vue';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource, getAllTags, listResources } from '../../../lib/resources';
import { getResource, getResourceFile, getAllTags, listResources } from '../../../lib/resources';
import { getAvailableTools } from '../../../lib/tools';
import { getAvailableModels } from '../../../lib/models';
@@ -43,6 +43,15 @@ for (const field of config.fields) {
}
}
}
// 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`}>

View File

@@ -1,6 +1,6 @@
import type { APIRoute } from 'astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource } from '../../../lib/resources';
import { getResource, getResourceFile } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
function parseList(val: unknown): string[] {
@@ -40,6 +40,28 @@ export const GET: APIRoute = async ({ params, url, request }) => {
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);
@@ -176,6 +198,221 @@ function getMainFileName(type: string): string {
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

@@ -1,6 +1,6 @@
import type { APIRoute } from 'astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource } from '../../../lib/resources';
import { getResource, getResourceFile } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
function parseList(val: unknown): string[] {
@@ -40,6 +40,28 @@ export const GET: APIRoute = async ({ params, url, request }) => {
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);
@@ -177,6 +199,271 @@ function getMainFileName(type: string): string {
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

@@ -1,14 +1,20 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../../lib/registry';
import { getResource, updateResource, deleteResource } from '../../../../lib/resources';
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) {
@@ -18,7 +24,15 @@ export const GET: APIRoute = async ({ params, request }) => {
// If JSON requested, include format and files metadata
const accept = request.headers.get('accept') || '';
if (accept.includes('application/json')) {
return new Response(JSON.stringify(resource), {
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' },
});
}
@@ -36,6 +50,7 @@ export const PUT: APIRoute = async ({ params, request }) => {
headers: { 'Content-Type': 'application/json' },
});
}
if (!isValidSlug(params.slug!)) return INVALID_SLUG;
let body: { content?: string };
try {
@@ -96,6 +111,7 @@ export const DELETE: APIRoute = async ({ params, request }) => {
headers: { 'Content-Type': 'application/json' },
});
}
if (!isValidSlug(params.slug!)) return INVALID_SLUG;
try {
const existing = await getResource(type, params.slug!);

View File

@@ -1,6 +1,6 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../../../../lib/registry';
import { getResource, getResourceFile, addResourceFile, deleteResourceFile } from '../../../../../../lib/resources';
import { getResource, getResourceFile, addResourceFile, deleteResourceFile, isValidSlug } from '../../../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../../../lib/tokens';
import { lookup } from 'mrmime';
@@ -20,7 +20,7 @@ async function checkAuth(request: Request, resource: { 'author-email': string; a
export const GET: APIRoute = async ({ params }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
if (!type || !isValidResourceType(type) || !filePath || !isValidSlug(slug!)) {
return new Response('Not found', { status: 404 });
}
@@ -47,7 +47,7 @@ export const GET: APIRoute = async ({ params }) => {
export const PUT: APIRoute = async ({ params, request }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
if (!type || !isValidResourceType(type) || !filePath || !isValidSlug(slug!)) {
return new Response(JSON.stringify({ error: 'Invalid params' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
@@ -82,7 +82,7 @@ export const PUT: APIRoute = async ({ params, request }) => {
export const DELETE: APIRoute = async ({ params, request }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
if (!type || !isValidResourceType(type) || !filePath || !isValidSlug(slug!)) {
return new Response(JSON.stringify({ error: 'Invalid params' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },