Claude Code Mods: un mod que no deja tocar tus tests

Claude Code Mods — Dominicode

Written by

in

, ,

La primera versión de mi mod de Claude Code solo vigilaba Bash.

Lo probé en Windows. Le pedí al agente que cambiara un test firmado de 409 a 201. Intentó apagar el candado y no pudo. Entonces tiró de PowerShell, la otra terminal que Claude Code tiene en Windows. Esa vez falló, pero no gracias a mí: mi mod no la vigilaba.

Ese es el problema de fondo. Con un test en rojo, el camino más corto al verde no es arreglar el código: es cambiar lo que espera el test. Los Claude Code Mods, que llegaron el 1 de octubre de 2026, son la primera herramienta que me deja cerrar esas puertas desde dentro.

En corto: un mod de Claude Code es un plugin con código TypeScript o JavaScript que se ejecuta dentro de Claude Code y puede observar, reescribir o responder a cada evento, como un middleware. Con menos de 80 líneas puedes bloquear cualquier Edit o Write sobre tus tests firmados y deshacer lo que el agente cambie desde la terminal. No va en sandbox y la API todavía puede cambiar entre versiones: úsalo como primera barrera, nunca como la última.

¿Qué son los Claude Code Mods?

Los Claude Code Mods son plugins cuyo hooks/hooks.json tiene la clave modules apuntando a un archivo TypeScript o JavaScript que Claude Code ejecuta en su propio proceso, enganchado a eventos como una llamada a herramienta, un prompt o el dibujado de la interfaz.

Llegaron con Claude Code v2.1.287, publicada el 1 de octubre de 2026 según el CHANGELOG oficial. Cómo funcionan está en la documentación oficial. Si tu claude --version es más antigua, no carga ninguno.

Cada hook recibe $, la API del motor; e, el evento; y next, lo que iba a pasar. Si vienes de Express, ya lo entiendes:

return next(e)                       // observar: que siga igual
return next({ ...e, text: nuevo })   // reescribir: que siga, pero cambiado
return { deny: 'No.' }               // responder (en tool.call): no llamas a next y no pasa

El problema: el agente "arregla" el test

Con un test en rojo, Claude Code tiende a cambiar la aserción en lugar de arreglar el código. No es una manía mía: en el repo de Claude Code está el issue #7074, abierto en septiembre de 2025 y cerrado como duplicado, que lo describe tal cual: el agente modifica los tests para que pasen en lugar de arreglar la implementación, cambia las aserciones y debilita validaciones. Que sea un duplicado dice bastante.

En mi demo, una API de reservas en Bun, los tests de test/contrato/ son los que he revisado y firmado. Uno dice que si dos personas reservan el mismo hueco, una recibe 201 y la otra 409. El código falla a propósito.

El agente puede tocar ese archivo por tres puertas: Edit, Write y la terminal (sed, echo > o PowerShell). Escribir "no toques los tests" en el CLAUDE.md no cierra ninguna: es una petición, no un control. Lo conté en hooks vs permisos en Claude Code.

Cómo crear un mod de Claude Code paso a paso

Crear un mod de Claude Code son tres archivos: el manifiesto del plugin, un hooks.json con modules y el módulo que exporta register(on). Sin compilar ni bundler.

El mod vive fuera del proyecto, en su carpeta. Primero, .claude-plugin/plugin.json (el nombre no puede empezar por claude-):

{
  "name": "tests-lock",
  "version": "0.1.0",
  "description": "Stops Claude from editing the signed tests in test/contrato/ and warns when a shell command changes them",
  "author": { "name": "Dominicode" }
}

Lo que convierte este plugin en un mod está en hooks/hooks.json:

{
  "description": "tests-lock hooks module",
  "modules": ["./register.ts"]
}

Primera puerta: Edit y Write

const PROTECTED = 'test/contrato/'
const REASON =
  'test/contrato/ es el contrato firmado y no se cambia para que pase. ' +
  'Arregla el código, no el test. Si crees que el contrato está mal, para y pregúntame.'

export function register(on) {
  // 1. Edit y Write: bloquear antes de que se toque un test firmado
  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
    if (!locked || !isProtected(e.file_path)) return next(e)
    // Crear un test nuevo sí se permite; cambiar uno que ya existe, no
    if (!(await $.fs.exists(e.file_path))) return next(e)
    // ...contador y aviso en pantalla
    return { deny: REASON }
  }).catch(async () => ({ deny: 'tests-lock ha fallado y, por seguridad, no se edita nada en ' + PROTECTED }))
}

El agente lee el deny como el error de la herramienta, así que lo escribo como instrucción. Crear un test nuevo sí se permite. Es un extracto: locked, isProtected y el resto están en el código completo, al final del post. Y el .catch hace que falle cerrado: si el hook revienta, la respuesta es "no". Sin él, Claude Code se salta el hook y la edición pasa.

Segunda puerta: la terminal

Esto es lo que justifica el mod. No sé de antemano qué toca un comando, y adivinarlo con regex es perder. Así que compruebo: miro git antes, dejo correr el comando y miro git después.

// Terminal (Bash, y PowerShell en Windows): se mira git antes y después
on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
  if (!locked) return next(e)
  const before = await changedTests($)
  const result = await next(e)
  const touched = (await changedTests($)).filter((f) => !before.includes(f))
  if (touched.length === 0) return result
  // Solo se restauran los que estaban limpios antes del comando
  await $.process.run(['git', 'checkout', 'HEAD', '--', ...touched])
  return {
    ...result,
    context: [...(result.context ?? []), 'Tu último comando cambió ' + touched.join(', ') + ' y se ha deshecho. ' + REASON],
  }
})

changedTests lanza git diff --name-only HEAD -- test/contrato/ con $.process.run, sin shell. El await next(e) ejecuta el comando y mi código sigue después.

context es texto que el agente lee tras el resultado y tú no ves. En mis pruebas, el agente citó el aviso y el archivo volvió a esperar 409. Fíjate en 'PowerShell': la añadí después de la historia del principio. Con Claude Code Mods, cada herramienta que no nombras es una puerta abierta.

Para cargarlo: claude --plugin-dir ~/mods/tests-lock. En /plugin lo ves cargado, y al guardar se recarga en caliente.

Un mod también pinta. Con ui.render sobre AbovePrompt dibujas una franja encima del prompt; por ejemplo, con el último resultado de bun test, Vitest o Jest.

El estado que lee esa franja vive en atom, read y update de 'claude-code'. Un hook de settings no puede dibujar.

Probar Claude Code Mods: claude plugin validate y claude plugin test

Un mod es código que corre dentro de tu herramienta de trabajo, así que se prueba como cualquier código.

claude plugin validate . lee el mod sin ejecutarlo y lista eventos y llamadas. Salida real:

❯ ./register.ts hooks: session.start, tool.call{tool=Edit|Write}, tool.call{tool=Bash|PowerShell}, command.run{command=tests-lock}
❯ ./register.ts calls: $.command.register, $.fs.exists, $.process.run, $.ui.status (via showStatus), $.ui.toast
✔ Validation passed

claude plugin test corre los *.test.ts con 'claude-code/testing', sin sesión ni red. Cada on es un stub que responde en lugar de Claude Code:

import { expect, test } from 'claude-code/testing'

test('editar un test firmado se bloquea', async ($, on) => {
  on('ui.status', () => ({ value: undefined }))
  on('ui.toast', () => ({ value: undefined }))
  on('fs.exists', () => ({ value: true }))
  on('tool.call', () => ({ result: 'edited' }))

  const r = await $.tool.call({ tool: 'Edit', file_path: 'C:\\proyectos\\agendo\\test\\contrato\\reservas.test.ts', old_string: '409', new_string: '201' })
  expect(r.deny).toMatch(/contrato firmado/)
})

Cinco tests en verde: bloqueo, test nuevo, código normal, comando de Bash deshecho e interruptor /tests-lock off.

Claude Code mods vs hooks de settings: cuál usar

Un hook de settings es un script que Claude Code lanza desde fuera y vive en el repo; un mod corre dentro de Claude Code, guarda estado, puede dibujar y actuar después de que la herramienta termine.

Hook de settings.json Mod
Dónde vive En .claude/settings.json, versionado en git En un plugin que cada dev instala
Qué escribes Un script en cualquier lenguaje TypeScript o JavaScript
Estado entre llamadas No, salvo un archivo temporal Sí, variables del módulo
Actuar tras la herramienta Con un segundo hook (PostToolUse) En el mismo hook, tras await next(e)
Interfaz y comandos propios No Sí
Tests Los que montes tú claude plugin test sin sesión
Limitación o riesgo Repartir estado entre dos scripts es frágil Sin sandbox, API que aún puede cambiar entre versiones, instalación por máquina

Mi regla: si todo el equipo tiene que cumplirla sin instalar nada, hook de settings en el repo. Si es tu herramienta y necesita estado, interfaz o mirar después del comando, mod. Lo de la terminal también sale con PreToolUse, PostToolUse y un archivo temporal; el mod lo junta en un archivo con tests.

Cuándo NO usar Claude Code Mods

No uses Claude Code Mods como única barrera ni instales un mod que no hayas leído.

No va en sandbox. La documentación lo dice: corre con tus permisos, lee tus variables de entorno y claves, ve cada prompt y puede aprobar llamadas que un hook tuyo bloqueó.

El sandbox de Claude Code no cubre lo que lanza un mod. Antes de instalar uno, claude plugin validate y lee los calls. Si algo va raro, claude --safe-mode arranca sin tus mods.

La API todavía puede cambiar. La documentación da los mods por activados por defecto desde la v2.1.287, pero la cabecera de los tipos avisa: "this surface may change between releases without notice".

Compara con el último commit. Lo no commiteado no cuenta como firmado. Y por cómo está escrito, si un mismo comando cambia el test y hace commit, el git diff posterior sale limpio. Lo deduzco del código; aún no lo he demostrado en sesión real.

Al recargar, el estado se reinicia. El contador vuelve a cero y el candado se enciende.

Solo protege donde está instalado. Otro compañero o el CI no lo tienen. La última barrera es el CI ejecutando los tests firmados en cada PR. Es la lógica de sacar la regla fuera del prompt: el agente propone, el sistema controla. Si aún no has montado tu primer agente, empieza por construir un agente de IA desde cero.

Lo que puedes hacer hoy con tu primer mod

Para empezar con Claude Code Mods: copia el register.ts completo del final del post, cambia test/contrato/ por tu carpeta, commitea los tests y carga el mod con --plugin-dir. Luego pide al agente que cambie una aserción, por Edit y por la terminal. Si estás en Windows y solo vigilas Bash, ya sabes por dónde se cuela.

Si estás empezando con agentes y quieres hacerlo sin perder el control, tienes gratis el ebook El Developer Agéntico. Lo monto entero en el canal de YouTube de Dominicode. Para el flujo completo, está el curso Construye con IA. Y los tests que merece la pena firmar salen de una spec: lo cuento en Spec-Driven Development.

Preguntas frecuentes

¿Qué versión de Claude Code necesito para usar mods?

La v2.1.287 o posterior, publicada el 1 de octubre de 2026. Compruébala con claude --version. Vienen activados por defecto y se cargan desde un plugin instalado o con --plugin-dir en desarrollo.

¿En qué se diferencian los hooks de Claude Code de los mods?

Un hook de settings es un script externo configurado en settings.json que recibe un JSON y devuelve una decisión. Un mod corre dentro de Claude Code, comparte variables entre hooks, puede dibujar, registrar comandos y actuar después de que una herramienta termine.

¿Un plugin de Claude Code con mod es seguro de instalar?

Solo si confías en quien lo escribió. No va en sandbox y corre con tus permisos. Antes de instalarlo, ejecuta claude plugin validate sobre su carpeta y revisa los hooks y calls que lista.

¿Puede el agente saltarse el mod usando la terminal?

Si el mod solo vigila Edit y Write, sí. Este mira git antes y después de cada comando de Bash y PowerShell y restaura los tests firmados que cambien. En Windows hay que vigilar las dos herramientas. Con un límite: si el mismo comando cambia el test y hace commit, el diff sale limpio. Por eso el CI es la última barrera.

¿Puedo crear un mod de Claude Code sin escribir el código?

Sí. La documentación oficial propone pedírselo a Claude en una sesión: Claude Code trae una skill para escribir mods. Revisa el resultado con claude plugin validate antes de cargarlo.

Código completo del mod

hooks/register.ts de tests-lock, las 78 líneas tal cual las uso:

// tests-lock: lo firmado en test/contrato/ no se toca para que pase.
const PROTECTED = 'test/contrato/'
const REASON =
  'test/contrato/ es el contrato firmado y no se cambia para que pase. ' +
  'Arregla el código, no el test. Si crees que el contrato está mal, para y pregúntame.'

let locked = true
let blocked = 0

function isProtected(path: string): boolean {
  return ('/' + path.replaceAll('\\', '/')).includes('/' + PROTECTED)
}

// Archivos de test/contrato/ que hoy no coinciden con el último commit
async function changedTests($): Promise<string[]> {
  try {
    const r = await $.process.run(['git', 'diff', '--name-only', 'HEAD', '--', PROTECTED])
    if (r.exitCode !== 0) return []
    return r.stdout.split('\n').map((l) => l.trim()).filter(Boolean)
  } catch {
    return []
  }
}

function showStatus($) {
  $.ui.status(locked ? `${PROTECTED} protegido · ${blocked} intento(s) frenado(s)` : `${PROTECTED} SIN proteger`)
}

export function register(on) {
  on('session.start', async ($, e, next) => {
    showStatus($)
    await $.command.register({
      name: 'tests-lock',
      description: 'Protege test/contrato/: on, off o status',
      argumentHint: '[on|off|status]',
      immediate: true,
    })
    return next(e)
  })

  // 1. Edit y Write: bloquear antes de que se toque un test firmado
  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
    if (!locked || !isProtected(e.file_path)) return next(e)
    // Crear un test nuevo sí se permite; cambiar uno que ya existe, no
    if (!(await $.fs.exists(e.file_path))) return next(e)
    blocked += 1
    showStatus($)
    $.ui.toast('Claude ha intentado editar ' + e.file_path)
    return { deny: REASON }
  }).catch(async () => ({ deny: 'tests-lock ha fallado y, por seguridad, no se edita nada en ' + PROTECTED }))

  // 2. Terminal (Bash, y PowerShell en Windows): no se sabe de antemano qué toca un comando,
  //    así que se mira git antes y después
  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
    if (!locked) return next(e)
    const before = await changedTests($)
    const result = await next(e)
    const touched = (await changedTests($)).filter((f) => !before.includes(f))
    if (touched.length === 0) return result
    // Solo se restauran los que estaban limpios antes del comando
    await $.process.run(['git', 'checkout', 'HEAD', '--', ...touched])
    blocked += 1
    showStatus($)
    $.ui.toast('Un comando ha cambiado ' + touched.join(', ') + '. Restaurado.')
    return {
      ...result,
      context: [...(result.context ?? []), 'Tu último comando cambió ' + touched.join(', ') + ' y se ha deshecho. ' + REASON],
    }
  })

  on('command.run', { command: 'tests-lock' }, async ($, e) => {
    const arg = e.args.trim()
    if (arg === 'on') locked = true
    if (arg === 'off') locked = false
    showStatus($)
    return { text: (locked ? 'Protegido: ' : 'Sin proteger: ') + PROTECTED + ' · intentos frenados: ' + blocked }
  })
}

Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *