Mods de Claude Code con un contador de cambios sin probar
Un mod de Claude Code es un plugin con funciones JavaScript o TypeScript que Claude Code ejecuta dentro de su propio proceso cuando ocurre un evento, como una llamada a herramienta o el dibujo de la interfaz. Puede observar, reescribir o responder ese evento, y compartir estado entre sus funciones sin archivos intermedios.
Los mods llegaron con la versión 2.1.287, publicada el 01/10/2026. Uno pequeño resuelve un problema concreto de revisión: saber qué archivos ha cambiado el agente desde el último test que pasó. Supongamos que pides un refactor, Claude toca siete archivos, ejecuta un test al principio y sigue editando. El mensaje final puede sonar a trabajo terminado, pero nada en pantalla indica qué quedó sin comprobar.
Cuándo compensa un mod frente a un hook o una skill
Un mod compensa cuando necesitas algo que ocurre dentro de Claude Code: compartir datos entre eventos, dibujar en la interfaz o lanzar un comando propio sin un turno del modelo. Para lo demás suele bastar una pieza más simple. Esta tabla resume la comparación oficial:
| Pieza | Qué es | Qué puede cambiar | Elígela si |
|---|---|---|---|
| Mod | Funciones JS o TS dentro del proceso | Llamadas a herramientas, prompts, comandos, turnos e interfaz | Quieres un panel, un comando propio o reescribir un evento |
| Settings hook | Comando, petición HTTP o prompt configurado en settings | Si una llamada sigue adelante, sus argumentos y resultado, y el contexto añadido | Quieres bloquear, permitir o registrar con un script que ya tienes |
| Skill | Un SKILL.md con instrucciones | Lo que Claude sabe y hace | Pegas una y otra vez las mismas instrucciones |
| Servidor MCP | Proceso o servicio externo | Las herramientas disponibles | Claude necesita llegar a un sistema externo |
Cuidado con el vocabulario: la documentación llama "hook" a la función de un mod y "settings hook" al que se configura en un archivo de ajustes. En este texto se sigue ese criterio.
El contador encaja en un mod porque varios eventos leen y escriben la misma lista. Con settings hooks puedes aproximarlo mediante un archivo temporal, varios scripts y una status line. Si ya tienes ese montaje y te sirve, no lo migres.
Crear el plugin con su manifiesto
Solo necesitas Claude Code v2.1.287 o posterior en la terminal, que compruebas con claude --version. No hacen falta Node.js, bundler ni compilación, porque Claude Code carga los archivos .js y .ts directamente. Este bloque crea los directorios, el manifiesto y el archivo que apunta a tu código:
mkdir -p sin-probar/.claude-plugin sin-probar/hooks sin-probar/tests
cat > sin-probar/.claude-plugin/plugin.json <<'EOF'
{ "name": "sin-probar", "version": "0.1.0",
"description": "Lista archivos cambiados sin un test posterior en verde",
"author": { "name": "Tu nombre" } }
EOF
cat > sin-probar/hooks/hooks.json <<'EOF'
{ "description": "Hooks de sin-probar", "modules": ["./register.js"] }
EOFLa clave modules es lo que convierte el plugin en un mod. Evita nombres que empiecen por claude-, porque claude plugin validate rechaza los que parecen de Anthropic.
Una lista compartida y un comando propio
Todo el estado cabe en una variable de módulo, porque los hooks de un mod comparten las variables de su archivo. Cada hook recibe tres argumentos: $ (la API del mod), e (el evento) y next (lo que hay que llamar para que el evento continúe). Empieza hooks/register.js con este bloque, que define la lista, la expresión que reconoce tus tests y el comando:
const pendientes = new Set()
const TEST = /\b(pytest|vitest|npm test|pnpm test|go test|cargo test)\b/
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'sinprobar',
description: 'Lista lo cambiado sin un test posterior en verde',
immediate: true,
})
return next(e)
})
Con immediate: true el comando funciona también mientras Claude trabaja. Adapta TEST a tu stack antes de usarlo.
Observar ediciones y tests sin alterar a Claude
La clave es esperar a next(e): así la comprobación de permisos y la herramienta se ejecutan primero, y tu hook decide con el resultado real. Devolver ese resultado intacto hace que Claude lea exactamente lo mismo que sin el mod. Continúa el archivo con el hook que apunta cada edición correcta:
on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
const result = await next(e)
if (!result.deny && !result.isError) {
pendientes.add(e.file_path)
$.ui.invalidate('ui.render')
}
return result
})
El evento tool.call también se dispara para las llamadas de los subagentes, así que sus ediciones entran en la lista. El siguiente hook vacía la lista solo si un comando de test termina bien:
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
const result = await next(e)
const enVerde = !result.deny && !result.isError
&& !result.result?.backgroundTaskId
if (TEST.test(e.command) && enVerde) {
pendientes.clear()
$.ui.invalidate('ui.render')
}
return result
})
Aquí hay dos condiciones que no son obvias:
isError: según los tipos publicados, solo aparece si la herramienta lanzó un error, se interrumpió o respondió con un error. La documentación de hooks incluye como fallo de herramienta unnpm testconExit code 1, pero no lo formula como regla, y por eso conviene comprobarlo más abajo.backgroundTaskId: un comando en segundo plano devuelve el control antes de acabar. Además, uno que empieza en primer plano puede pasar a segundo plano, por ejemplo al llegar a su timeout. Sin este filtro contarías como verde un test que sigue corriendo.
Mostrar el resultado en el spinner y en el comando
Los dos últimos hooks solo leen la lista. El primero responde sin llamar a next, porque el comando no tiene otro comportamiento; el segundo añade el contador al spinner. Cierra el archivo con ellos:
on('command.run', { command: 'sinprobar' }, async () => {
if (pendientes.size === 0) return { text: 'Nada pendiente.' }
return { text: 'Sin test en verde:\n' + [...pendientes].join('\n') }
})
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
if (pendientes.size === 0) return next(e)
const suffix = ' · sin probar: ' + pendientes.size + '…'
return next({ ...e, props: { ...e.props, suffix } })
})
}
El texto que devuelve un comando se imprime en la conversación y Claude también lo lee. Por eso, tras /sinprobar puedes pedirle sin más que escriba o ejecute los tests de esos archivos.
Validar y testear el mod sin abrir sesión
El kit de pruebas dispara eventos sin sesión, sin login y sin red, y un stub responde en lugar de Claude Code. Guarda esto en tests/sin-probar.test.ts:
import { expect, test } from 'claude-code/testing'
test('un test en verde vacía la lista', async ($, on) => {
on('tool.call', () => ({ result: 'ok' }))
await $.tool.call({ tool: 'Edit', file_path: 'src/a.py' })
let out = await $.command.run({ command: 'sinprobar', args: '' })
expect(out.text).toContain('src/a.py')
await $.tool.call({ tool: 'Bash', command: 'pytest -q' })
out = await $.command.run({ command: 'sinprobar', args: '' })
expect(out.text).toBe('Nada pendiente.')
})
Añade al mismo archivo un segundo caso: un test que no llega a ejecutarse porque se deniega no debe vaciar la lista.
test('un test denegado no vacía la lista', async ($, on) => {
on('tool.call', ($, e) =>
e.tool === 'Bash' ? { deny: 'Denegado' } : { result: 'ok' })
await $.tool.call({ tool: 'Edit', file_path: 'src/a.py' })
await $.tool.call({ tool: 'Bash', command: 'pytest -q' })
const out = await $.command.run({ command: 'sinprobar', args: '' })
expect(out.text).toContain('src/a.py')
})
Estos comandos revisan el mod, ejecutan sus tests y lo cargan para una sesión:
# Lee el manifiesto y el código sin ejecutarlo
claude plugin validate ./sin-probar
# Ejecuta los tests desde el directorio del mod
cd sin-probar
claude plugin test
cd ..
# Carga el mod solo para esta sesión
claude --plugin-dir ./sin-probarSegún la documentación, validate imprime una línea hooks: con los eventos y otra calls: con los métodos usados. Aquí calls: debería mostrar solo $.command.register y $.ui.invalidate. claude plugin test debería indicar 2 pass y 0 fail, y sale con estado 1 si algo falla, así que sirve en CI.
Comprobación real con un test en rojo
Los stubs prueban tu lógica, no el comportamiento real de Claude Code, así que falta una comprobación en una sesión. El resultado esperado es este:
- Pide a Claude que edite dos archivos. El spinner añade
· sin probar: 2…. - Ejecuta
/sinprobar. Aparecen las dos rutas, precedidas por el nombre del plugin. - Rompe un test adrede y pide a Claude que lo ejecute. La lista debe seguir igual.
- Arregla el test y pide que lo ejecute otra vez.
/sinprobarrespondeNada pendiente.
Si la lista se vacía en el paso 3, el test en rojo no llega como isError en tu versión. Consulta entonces los tipos que Claude Code genera en .claude-plugin/types/, porque la documentación pide fiarse de ellos antes que de cualquier página.
Si no ocurre nada, recuerda que un mod roto puede parecer uno que no hace nada. Arranca con claude --debug-file ./mod.log --plugin-dir ./sin-probar y busca en el log el nombre del mod y hook skipped.
Qué no garantiza este contador
El contador es una señal para revisar, no una prueba de calidad. Tiene límites que conviene conocer antes de fiarte de un cero:
- Cambios por Bash: un
sed -io un formateador ejecutado en shell no pasan porEditniWrite, y no se cuentan. - Tests parciales: cualquier comando verde vacía toda la lista, aunque solo ejecute un archivo que no tiene relación con los cambios.
- Salidas maquilladas:
pytest || truetermina con éxito aunque fallen los tests. - Recargas: las variables de módulo se reinician en cada recarga, así que la lista se vacía al guardar el mod. Para conservarla, usa
$.stateo$.store. - Sin interfaz: en
claude -py en sesiones cloud los hooks corren, pero lo que el mod dibuja no aparece.
Cuándo no escribir un mod
No uses un mod si una regla, un settings hook o tu CI ya resuelven el problema, porque un mod añade código con mucho alcance a tu sesión. En concreto:
- Si quieres impedir un merge sin tests: ponlo en CI o en la protección de ramas. Un mod solo actúa en la sesión donde está cargado.
- Si un guard debe bloquear siempre: por defecto, un hook que lanza una excepción o supera su límite se omite y la acción continúa. Necesitas un
.catchque deniegue. El límite es de 10 segundos para el hook y 1 segundo para el.catch. - Si tu organización lo restringe: con
allowManagedModsOnlyno se ejecutarán los hooks de los mods que traigas tú.
La decisión de seguridad pesa más que la técnica. Los mods no están en sandbox y se ejecutan con tus permisos. Además, las reglas de permisos se aplican a las herramientas de Claude, no a las llamadas $.fs y $.process del mod: con Read(.env) denegado, un mod aún puede leer ese archivo.
Si usas una API key, sin ajustes gestionados ni login Team o Enterprise, un mod puede aprobar una llamada que una regla deny rechaza. Antes de instalar el mod de otra persona, aplica esta lista:
- Descarga sus archivos y ejecuta
claude plugin validatesobre el directorio. - Revisa en
calls:si usa$.process,$.http.fetch,$.env.geto$.fs, y si tiene sentido para lo que promete. - Revisa en
hooks:si aparecetool.check, que le permite aprobar o denegar llamadas antes de que se te pregunte. - Comprueba que el autor y el marketplace sean de tu confianza.
Preguntas frecuentes
¿Puede Claude escribir el mod?
Sí. Descríbelo en una sesión interactiva o ejecuta /plugin-authoring. El mod se guarda en ~/.claude/dev-mods/, en una carpeta de esa sesión, y solo se carga en ella. Para conservarlo, copia su directorio a otra ruta y cárgalo con --plugin-dir.
¿Cómo se desactiva un mod que da problemas?
Desactívalo en la pestaña Installed de /plugin. Si quieres comprobar si la causa es un mod instalado, arranca con --safe-mode, que desactiva también tus otras personalizaciones durante esa sesión.
¿Cómo se comparte un mod con otras personas?
Un mod se distribuye como cualquier plugin: puedes enviar su directorio o publicarlo en un marketplace propio. Mientras lo desarrollas, trabaja con --plugin-dir, porque la copia instalada queda en caché por versión y no recibe tus ediciones hasta que subas la versión y lo reinstales.