Status line with git branch and pending changes, DDEV state and OpenSpec progress.

Claude Code mods para proyectos Drupal que usan DDEV.
Pide confirmación antes de comandos que destruyen datos o código que git no puede recuperar: reinstalar el sitio, vaciar tablas, borrar entidades en bloque, rm -rf, ddev delete, restaurar un snapshot, o descartar cambios de git. Una regla deny existente se respeta igual.
Tras editar o crear un fichero de un módulo custom (.php, .module, .install, .inc, .theme, .profile), ejecuta phpcs (estándares Drupal y DrupalPractice) dentro de DDEV sobre ese fichero y añade el informe al contexto para que Claude lo corrija en el mismo turno.
Por defecto busca los módulos en web/modules/custom/. Si el proyecto usa otra raíz (p. ej. docroot/), fija la variable de entorno DRUPAL_WEBROOT:
claude mcp add ... # no aplica aquí; para plugins usa:
claude plugin install drupal-phpcs --marketplace o0Charly0o/drupal-mods -s user -e DRUPAL_WEBROOT=docroot
Si DDEV no está levantado o phpcs no existe, el hook nunca bloquea la edición.
Barra de estado: rama de git y cambios pendientes, si el contenedor web de DDEV está arriba, y el progreso (hecho/total) de cada change activo en openspec/changes/.
claude plugin install drupal-guard --marketplace o0Charly0o/drupal-mods -s user
claude plugin install drupal-phpcs --marketplace o0Charly0o/drupal-mods -s user
claude plugin install ddev-status --marketplace o0Charly0o/drupal-mods -s user
Con -s user quedan disponibles en todos los proyectos; fuera de un proyecto Drupal/DDEV simplemente no se activan.
MIT
hooks/register.ts 103 lines1import type { EngineInterface, Register } from 'claude-code'
2
3
4const TASK = /^\s*- \[([ xX])\]/gm
5
6/**
7 * Status line: "master ●3 · DDEV ✓ · user-library 13/15".
8 * Refreshed when the session starts, after every turn and every 30 seconds.
9 */
10export const register: Register = on => {
11 let cancel: (() => void) | undefined
12
13 on('session.start', async ($, e, next) => {
14 const started = await next(e)
15 void refresh($)
16 cancel?.()
17 cancel = $.clock.every(30000, () => refresh($)).cancel
18 return started
19 })
20
21 on('turn.complete', async ($, e, next) => {
22 const done = await next(e)
23 void refresh($)
24 return done
25 })
26}
27
28/**
29 * Recomputes the status line.
30 */
31async function refresh($: EngineInterface): Promise<void> {
32 const parts: string[] = []
33
34 const branch = await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
35 if (branch !== null) {
36 const changes = (await run($, ['git', 'status', '--porcelain']))?.split('\n').filter(Boolean).length ?? 0
37 parts.push(changes > 0 ? `${branch} ●${changes}` : branch)
38 }
39
40 const ddev = await readDdevName($)
41 if (ddev !== null) {
42 const state = await run($, ['docker', 'ps', '--filter', `name=ddev-${ddev}-web`, '--format', '{{.Status}}'])
43 parts.push(state ? 'DDEV ✓' : 'DDEV ✗')
44 }
45
46 parts.push(...(await openspecProgress($)))
47 $.ui.status(parts.length ? parts.join(' · ') : undefined)
48}
49
50/**
51 * Runs a command; its trimmed output on success, null otherwise.
52 */
53async function run($: EngineInterface, argv: readonly string[]): Promise<string | null> {
54 try {
55 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 10000 })
56 return exitCode === 0 ? stdout.trim() : null
57 }
58 catch {
59 return null
60 }
61}
62
63/**
64 * Returns the DDEV project name from .ddev/config.yaml, if any.
65 */
66async function readDdevName($: EngineInterface): Promise<string | null> {
67 try {
68 const config = String(await $.fs.read('.ddev/config.yaml'))
69 return config.match(/^name:\s*["']?([\w.-]+)/m)?.[1] ?? null
70 }
71 catch {
72 return null
73 }
74}
75
76/**
77 * Returns "change done/total" for each active OpenSpec change.
78 */
79async function openspecProgress($: EngineInterface): Promise<string[]> {
80 try {
81 const entries = await $.fs.list('openspec/changes')
82 const progress: string[] = []
83 for (const entry of entries) {
84 if (entry.kind !== 'dir' || entry.name === 'archive') {
85 continue
86 }
87 try {
88 const tasks = String(await $.fs.read(`openspec/changes/${entry.name}/tasks.md`))
89 const marks = [...tasks.matchAll(TASK)].map(match => match[1])
90 const done = marks.filter(mark => mark !== ' ').length
91 progress.push(done === marks.length && marks.length > 0 ? `${entry.name} ✓` : `${entry.name} ${done}/${marks.length}`)
92 }
93 catch {
94 progress.push(`${entry.name} (no tasks)`)
95 }
96 }
97 return progress
98 }
99 catch {
100 return []
101 }
102}
103