Claude Code con ESLint: el linter corre solo tras cada edición
En la práctica/ 13 min de lectura
Un hook PostToolUse que pasa ESLint, Prettier o Biome por cada archivo que Claude edita y le devuelve los errores, en bash y PowerShell.
- IA
- Claude Code
En este artículo (8)
Para que Claude Code pase ESLint después de cada cambio, usa un hook PostToolUse con matcher Edit|Write: Claude Code lo ejecuta en cuanto termina la edición, el hook corre el linter solo sobre ese archivo y, si quedan errores, sale con código 2 para que Claude lea el reporte y lo corrija en la misma sesión.
En esta guía armamos ese hook para ESLint, Prettier o Biome, en bash y en PowerShell, limitado a archivos .ts, .tsx y .js. Lo probamos con las tres herramientas y dentro de Claude Code 2.1.238. Es el artículo 5 de la serie de Claude Code para developers; el anterior, sobre el hook que protege tu .env, usa el evento opuesto, PreToolUse.
Qué es PostToolUse y qué recibe el hook
Un hook es un comando tuyo que Claude Code ejecuta en un momento fijo de su ciclo de trabajo. PostToolUse corre justo después de que una herramienta termina con éxito. Si PreToolUse es el portero que decide si algo pasa, PostToolUse es el revisor que mira lo que ya pasó. La diferencia con decirle a Claude «corre el linter al terminar» es que la instrucción depende de que el modelo se acuerde; el hook corre siempre.
Por stdin el script recibe un JSON con tool_name, tool_input (los argumentos que Claude mandó) y tool_response (lo que devolvió la herramienta). Para este hook basta un campo: tool_input.file_path, la ruta del archivo que Claude acaba de crear o editar.
Desliza la figura para verla completa
Lo que el hook responde se decide por el código de salida, según la referencia de hooks:
| Código | Qué pasa en PostToolUse | Quién se entera |
|---|---|---|
0 | Todo bien. Claude nunca ve el stderr. | Nadie |
2 | No puede bloquear (la edición ya ocurrió), pero el stderr llega a Claude junto al resultado de la herramienta. | Claude |
| Otro | Error que no bloquea: aparece un aviso con la primera línea del stderr. | Tú |
Ese reparto es el que usa el script: los errores del código van a Claude con exit 2, y los problemas de instalación o de configuración van a ti con exit 1, para que Claude no intente «arreglar» tu eslint.config.js.
Configuración en .claude/settings.json
Paso 1: registra el hook
En la raíz del proyecto, dentro de .claude/settings.json (el que se versiona y comparte el equipo):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-archivo.sh"
}
]
}
]
}
}
El matcher es una lista de nombres exactos de herramientas: Edit|Write cubre las dos que modifican archivos. La guía oficial de hooks usa el mismo matcher para su ejemplo con Prettier. $CLAUDE_PROJECT_DIR es la raíz del proyecto y va entre comillas porque la ruta puede tener espacios.
Paso 2: el script en bash
Guárdalo como .claude/hooks/lint-archivo.sh y, en macOS o Linux, dale permiso de ejecución con chmod +x.
#!/usr/bin/env bash
# lint-archivo.sh: hook PostToolUse de Claude Code.
# Después de cada Edit o Write sobre un .ts, .tsx o .js, pasa el linter solo por
# ese archivo. Lo que el linter no pudo corregir vuelve a Claude por el stderr.
# exit 0 = limpio o no aplica · exit 2 = errores para Claude · exit 1 = aviso para ti.
# Requiere jq: https://jqlang.org/download/
# eslint, prettier o biome.
linter="eslint"
command -v jq >/dev/null 2>&1 || { echo "lint-archivo: falta jq" >&2; exit 1; }
ruta=$(jq -r '.tool_input.file_path // empty') || { echo "lint-archivo: entrada no válida" >&2; exit 1; }
case "$ruta" in
*.ts|*.tsx|*.js) ;;
*) exit 0 ;;
esac
# Usa el linter instalado en el proyecto, no uno global ni uno descargado al vuelo.
raiz=${CLAUDE_PROJECT_DIR:-$PWD}
bin="$raiz/node_modules/.bin/$linter"
[ -x "$bin" ] || { echo "lint-archivo: no encuentro $bin" >&2; exit 1; }
case "$linter" in
eslint) salida=$("$bin" --fix "$ruta" 2>&1) ;;
prettier) salida=$("$bin" --write "$ruta" 2>&1) ;;
biome) salida=$("$bin" check --write "$ruta" 2>&1) ;;
*) echo "lint-archivo: linter desconocido: $linter" >&2; exit 1 ;;
esac
codigo=$?
[ $codigo -eq 0 ] && exit 0
# ESLint sale con 2 cuando falla su configuración, no tu código: eso es para ti.
if [ "$linter" = eslint ] && [ $codigo -eq 2 ]; then
echo "lint-archivo: ESLint no pudo correr. $(echo "$salida" | head -n 3)" >&2
exit 1
fi
# Solo las primeras 30 líneas: suficiente para corregir, sin llenarle el contexto a Claude.
{
echo "El linter ($linter) encontró problemas en $ruta:"
echo "$salida" | head -n 30
} >&2
exit 2
Lo que hace, en orden:
- Filtra por extensión. Un
.md, un.jsono un.csssalen con 0 sin gastar tiempo. - Usa el linter del proyecto. Llama a
node_modules/.bin/eslintdirectamente, sinnpx: así no descarga nada ni usa una versión global distinta a la de tupackage.json, y se ahorra el arranque denpx. - Corre el linter sobre un solo archivo y deja que corrija lo que pueda (
--fix,--write). - Reporta lo que queda. Si el linter sale con error, manda a stderr las primeras 30 líneas y sale con 2.
- Separa tus problemas de los de Claude. Sin linter instalado, o con ESLint saliendo con 2 (su código para «falló la configuración»), avisa con exit 1.
Paso 3: la versión para Windows
Claude Code ejecuta los hooks con Git Bash si está instalado y con PowerShell si no. Esta versión no necesita jq:
# lint-archivo.ps1: hook PostToolUse de Claude Code para Windows (PowerShell 5.1 o 7).
# Después de cada Edit o Write sobre un .ts, .tsx o .js, pasa el linter solo por
# ese archivo. Lo que el linter no pudo corregir vuelve a Claude por el stderr.
# exit 0 = limpio o no aplica · exit 2 = errores para Claude · exit 1 = aviso para ti.
# Los mensajes van sin acentos: PowerShell 5.1 lee como ANSI un .ps1 UTF-8 sin BOM.
# eslint, prettier o biome.
$linter = 'eslint'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
try {
$entrada = [Console]::In.ReadToEnd() | ConvertFrom-Json
} catch {
[Console]::Error.WriteLine('lint-archivo: entrada no valida')
exit 1
}
$ruta = $entrada.tool_input.file_path
if (-not $ruta -or $ruta -notmatch '\.(ts|tsx|js)$') { exit 0 }
# Usa el linter instalado en el proyecto, no uno global ni uno descargado al vuelo.
$raiz = if ($env:CLAUDE_PROJECT_DIR) { $env:CLAUDE_PROJECT_DIR } else { (Get-Location).Path }
$bin = Join-Path $raiz "node_modules\.bin\$linter.cmd"
if (-not (Test-Path -LiteralPath $bin)) {
[Console]::Error.WriteLine("lint-archivo: no encuentro $bin")
exit 1
}
switch ($linter) {
'eslint' { $salida = & $bin --fix $ruta 2>&1 }
'prettier' { $salida = & $bin --write $ruta 2>&1 }
'biome' { $salida = & $bin check --write $ruta 2>&1 }
default { [Console]::Error.WriteLine("lint-archivo: linter desconocido: $linter"); exit 1 }
}
$codigo = $LASTEXITCODE
if ($codigo -eq 0) { exit 0 }
# Con 2>&1, PowerShell 5.1 envuelve cada linea de stderr en un ErrorRecord; el
# texto original esta en TargetObject (si no, las lineas vacias salen como
# "System.Management.Automation.RemoteException").
$lineas = $salida | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) { "$($_.TargetObject)" } else { "$_" }
}
# ESLint sale con 2 cuando falla su configuracion, no tu codigo: eso es para ti.
if ($linter -eq 'eslint' -and $codigo -eq 2) {
[Console]::Error.WriteLine("lint-archivo: ESLint no pudo correr. $(($lineas | Select-Object -First 3) -join ' ')")
exit 1
}
# Solo las primeras 30 lineas: suficiente para corregir, sin llenarle el contexto a Claude.
[Console]::Error.WriteLine("El linter ($linter) encontro problemas en ${ruta}:")
$lineas | Select-Object -First 30 | ForEach-Object { [Console]::Error.WriteLine($_) }
exit 2
Un detalle que salió en las pruebas: con 2>&1, Windows PowerShell 5.1 envuelve cada línea de stderr del linter en un ErrorRecord, y las líneas vacías se convertían en el texto System.Management.Automation.RemoteException, que le llegaba a Claude como ruido. Por eso el script toma el texto original de TargetObject. La configuración para esta versión usa la forma «exec» con args, que no necesita comillas y salta la política de ejecución:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-archivo.ps1"
]
}
]
}
]
}
}
ESLint, Prettier y Biome: el comando de cada uno
El script trae los tres; cambias la variable linter de la primera línea. Los tres se instalan en el proyecto y el hook los busca en node_modules/.bin:
Desliza la figura para verla completa
| Herramienta | Comando del hook | Qué corrige solo | Cuándo vuelve a Claude |
|---|---|---|---|
| ESLint | eslint --fix <archivo> | Lo que sus reglas pueden arreglar | Errores que quedan (sale con 1) |
| Prettier | prettier --write <archivo> | Todo el formato | Cuando no puede leer el archivo (sale con 2) |
| Biome | biome check --write <archivo> | Formato y arreglos seguros de lint | Errores que quedan |
Probamos el hook con ESLint 10.13 (con typescript-eslint), Prettier 3.9 y Biome 2.6, en bash y en PowerShell, con cuatro casos cada uno: un .md que se salta, un archivo limpio, uno con un error de sintaxis y un proyecto sin el linter instalado. Los doce casos por shell dieron el código esperado. Si usas Prettier y ESLint juntos, córrelos uno después del otro dentro del mismo script, no como dos hooks: según la referencia, los hooks de un mismo evento corren en paralelo, y los dos reescribirían el mismo archivo a la vez.
Lintear solo el archivo que cambió: rendimiento en repos grandes
El hook corre en cada Edit y cada Write. En una sesión normal eso son decenas de ejecuciones, así que pasarle al linter la carpeta entera en vez del archivo multiplica la espera. Lo medimos en el repositorio de este sitio, 412 archivos .ts, .tsx, .js y .mjs con ESLint 8.57: con la caché del sistema caliente, ESLint tardó unos 3 segundos sobre un archivo y unos 9 segundos sobre todo el proyecto (mediana de tres rondas). La primera ejecución en frío tardó 35 segundos. Dentro de Claude Code, el registro de depuración marcó el hook con Slow PostToolUse hooks: 2306ms for Edit.
Tres ajustes que ayudan cuando el repo crece:
- Un archivo, nunca la carpeta. Es lo que hace el script con
tool_input.file_path. - La caché de ESLint.
--cacheguarda qué archivos ya revisó en.eslintcache(agrégalo a.gitignore). La documentación de ESLint advierte que no sigue dependencias entre archivos, así que reglas que miran importaciones pueden dar resultados viejos. - Un tiempo límite. El valor por defecto para un hook de tipo
commandes de 10 minutos. Si tu linter puede colgarse, agrega"timeout": 60al hook ensettings.json.
Qué pasa cuando hay un error que no se corrige solo
Lo comprobamos en un proyecto de prueba con ESLint y typescript-eslint, y el hook de PowerShell. Le pedimos a Claude agregar al final de src/carrito.ts exactamente la línea const sinUso = 1;. La regla @typescript-eslint/no-unused-vars la marca como error. Así se vio:
La respuesta de Claude menciona el reporte, y el registro de depuración (--debug-file) confirma de dónde vino: Hook PostToolUse:Edit (PostToolUse) error: El linter (eslint) encontro problemas en ...carrito.ts. En la segunda parte de la captura, el mismo hook ejecutado a mano imprime el error y sale con 2. La ruta larga del proyecto de prueba está abreviada con «…».
Si el error tiene arreglo y Claude no tiene una orden en contra, normalmente lo corrige en el siguiente paso, y el hook vuelve a correr sobre la nueva versión. En otra prueba le pedimos a Claude una constante que «por ahora no se usa»: después de crear el archivo volvió a editarlo para agregar // eslint-disable-next-line. Es una salida válida si tú lo pediste, y un problema si no. Si no quieres que Claude calle al linter, escríbelo en tu CLAUDE.md.
Si prefieres que el reporte llegue como dato estructurado en lugar de stderr, la referencia de hooks permite salir con 0 e imprimir un JSON con "decision": "block" y "reason", o con additionalContext. Para un linter, el stderr con exit 2 es lo más simple y Claude lo entiende igual.
Errores comunes
- Salir con 1 cuando quedan errores. En
PostToolUsesolo el exit 2 le muestra el stderr a Claude. Con 1 lo ves tú en un aviso y Claude sigue como si nada. - Pasar la carpeta en vez del archivo.
eslint .en cada edición es la forma más rápida de hacer lenta una sesión. - Usar
npx eslintsinnode_modules. Si el paquete no está instalado,npxpuede descargar otra versión. Llama al binario del proyecto. - Mandarle a Claude los errores de configuración. Un
eslint.config.jsroto no es culpa del archivo que Claude editó. Ese caso va a ti. - Volcar todo el reporte. Cientos de líneas de lint llenan el contexto de Claude. Con 30 alcanza para corregir.
- Esperar que el hook vea todo.
PostToolUseconEdit|Writeno corre cuando un comando de terminal reescribe el archivo. - Olvidar el
chmod +xen macOS o Linux, ojqen Git Bash.
Preguntas frecuentes
¿PostToolUse puede impedir que Claude guarde un archivo con errores?
No. Corre después de que la herramienta terminó, así que el archivo ya está escrito. Lo que puede hacer es avisarle a Claude para que lo corrija. Para impedir una acción antes de que ocurra está PreToolUse.
¿Por qué no lintear todo al final, con un hook Stop?
Puedes, y sirve como red final. Pero si Claude edita diez archivos y el error está en el primero, se entera al final y tiene que volver. Con PostToolUse lo corrige mientras todavía tiene ese archivo en mente.
¿Funciona con el formateo de Prettier aunque no haya errores?
Sí. prettier --write reescribe el archivo y sale con 0, así que Claude no ve nada y el archivo queda formateado. Ten en cuenta que la próxima vez que Claude lo lea verá la versión formateada, no la que escribió.
¿Cuánto tarda cada ejecución?
Depende del linter y del tamaño del proyecto. En nuestras pruebas, ESLint con typescript-eslint tardó entre 1,6 y 3 segundos por archivo, y Prettier y Biome menos de un segundo. Son números de una máquina concreta (Ryzen 7 5700G, Windows 11): mídelo en la tuya antes de decidir.
¿Puedo usarlo en todos mis proyectos?
Sí, poniéndolo en ~/.claude/settings.json, pero cada proyecto necesita su propio linter instalado. El script lo busca en el node_modules del proyecto en el que estás y, si no está, solo te avisa.
Checklist
.claude/settings.jsonregistra el hook enPostToolUsecon"matcher": "Edit|Write".- La variable
linterdel script diceeslint,prettierobiome, y ese paquete está endevDependencies. - Los cuatro casos de
probar-lintterminan en «Todo en orden.». - Una edición con un error a propósito le llega a Claude y la corrige.
/hooksmuestra el hook, y el tiempo por edición te resulta aceptable.- Tu
CLAUDE.mddice si Claude puede o no usareslint-disable.
Con esto, el linter deja de ser algo que corres al final y se vuelve parte de cada edición. Si todavía no proteges tus secretos, el paso anterior es el hook PreToolUse que bloquea tu .env; para ver cuándo conviene un hook y cuándo una skill o un subagente, está skills vs subagentes vs hooks vs plugins, y para llevar el mismo control a tu integración continua, Claude Code en GitHub Actions. El método general está en mi flujo de 4 pasos con Claude Code.
¿Viste esto en Instagram? Comenta LINTER en el Reel o el carrusel y te mando la guía.
Escrito por

Publicado el 26 de octubre de 2026 · actualizado el 28 de octubre de 2026