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:
@@ -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\"}}}"]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
10
.claude/skills/testeo/SKILL.md
Normal file
10
.claude/skills/testeo/SKILL.md
Normal 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
|
||||
1
.claude/skills/testeo/assets/temp.tpl
Normal file
1
.claude/skills/testeo/assets/temp.tpl
Normal file
@@ -0,0 +1 @@
|
||||
template
|
||||
1
.claude/skills/testeo/references/readme.md
Normal file
1
.claude/skills/testeo/references/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
referencia
|
||||
1
.claude/skills/testeo/scripts/run.sh
Executable file
1
.claude/skills/testeo/scripts/run.sh
Executable file
@@ -0,0 +1 @@
|
||||
bash
|
||||
368
PLAN-HOOKS.md
Normal file
368
PLAN-HOOKS.md
Normal 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
257
PLAN.md
@@ -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
|
||||
|
||||
37
data/agents/example-agent.md
Normal file
37
data/agents/example-agent.md
Normal 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
0
data/claude-md/.gitkeep
Normal file
19
data/output-styles/example-style.md
Normal file
19
data/output-styles/example-style.md
Normal 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
|
||||
38
data/rules/example-rule.md
Normal file
38
data/rules/example-rule.md
Normal 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
|
||||
10
data/skills/testeo/SKILL.md
Normal file
10
data/skills/testeo/SKILL.md
Normal 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
|
||||
1
data/skills/testeo/assets/temp.tpl
Normal file
1
data/skills/testeo/assets/temp.tpl
Normal file
@@ -0,0 +1 @@
|
||||
template
|
||||
1
data/skills/testeo/references/readme.md
Normal file
1
data/skills/testeo/references/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
referencia
|
||||
1
data/skills/testeo/scripts/run.sh
Normal file
1
data/skills/testeo/scripts/run.sh
Normal file
@@ -0,0 +1 @@
|
||||
bash
|
||||
@@ -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
21
skills-here.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
479
src/components/HookConfigEditor.vue
Normal file
479
src/components/HookConfigEditor.vue
Normal 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>
|
||||
91
src/components/HookConfigPreview.astro
Normal file
91
src/components/HookConfigPreview.astro
Normal 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>
|
||||
)}
|
||||
@@ -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 · <strong class="text-gray-500">references/</strong> Claude reads as context · <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 · <strong class="text-gray-500">references/</strong> Claude reads as context · <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) {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
|
||||
219
src/pages/[type]/[slug]/uninstall.ts
Normal file
219
src/pages/[type]/[slug]/uninstall.ts
Normal 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');
|
||||
}
|
||||
@@ -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!);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user