Mods de Claude Code con un contador de cambios sin probar

Ilustración abstracta de bloques geométricos encadenados en una línea, con varios módulos en ámbar a la espera de una marca de validación verde.

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:

PiezaQué esQué puede cambiarElígela si
ModFunciones JS o TS dentro del procesoLlamadas a herramientas, prompts, comandos, turnos e interfazQuieres un panel, un comando propio o reescribir un evento
Settings hookComando, petición HTTP o prompt configurado en settingsSi una llamada sigue adelante, sus argumentos y resultado, y el contexto añadidoQuieres bloquear, permitir o registrar con un script que ya tienes
SkillUn SKILL.md con instruccionesLo que Claude sabe y hacePegas una y otra vez las mismas instrucciones
Servidor MCPProceso o servicio externoLas herramientas disponiblesClaude 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"] }
EOF

La 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:

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-probar

Segú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:

  1. Pide a Claude que edite dos archivos. El spinner añade · sin probar: 2….
  2. Ejecuta /sinprobar. Aparecen las dos rutas, precedidas por el nombre del plugin.
  3. Rompe un test adrede y pide a Claude que lo ejecute. La lista debe seguir igual.
  4. Arregla el test y pide que lo ejecute otra vez. /sinprobar responde Nada 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 -i o un formateador ejecutado en shell no pasan por Edit ni Write, 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 || true termina 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 $.state o $.store.
  • Sin interfaz: en claude -p y 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:

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 validate sobre el directorio.
  • Revisa en calls: si usa $.process, $.http.fetch, $.env.get o $.fs, y si tiene sentido para lo que promete.
  • Revisa en hooks: si aparece tool.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.

Compartir

X LinkedIn
Volver al blog