Линтер обычно запускается позже, чем хотелось бы: на pre-commit, в CI, иногда только при код-ревью. Когда файлы правит агент, разрыв заметнее. Claude вносит десяток изменений подряд, вы принимаете их одно за другим, а про неиспользуемый импорт и забытый await узнаёте через полчаса, когда упал пайплайн.
Проблему решает хук. В этом уроке мы соберём PostToolUse-хук, который запускает eslint сразу после каждой правки и возвращает найденные проблемы модели, чтобы Claude сам их исправил. Пройдём путь от однострочника в settings.json до скрипта с фильтрацией по расширениям.
Что уже умеем / что добавим
Вы уже знаете события жизненного цикла, разницу между PreToolUse и PostToolUse, три уровня вложенности в конфигурации и правила матчеров. Знаете и коды выхода: 0 — успех, 2 — блокирующая ошибка, остальное — неблокирующая.
Теперь применим это к живой задаче и добавим три приёма: чтение входного JSON из stdin через jq, ранний выход по условию и осознанное использование exit 2 на PostToolUse, где блокировать нечего, но обратную связь модели передать можно.
Что вы узнаете в рамках статьи
- Почему просьба в
CLAUDE.mdне гарантирует запуск линтера, а хук гарантирует - Как получить первую рабочую версию одной строкой в
settings.json - Как вынести логику в скрипт и извлечь путь к файлу из входного JSON
- Как вернуть вывод eslint модели через stderr и
exit 2 - Как проверить хук вручную и почему
Edit|Writeпокрывает не всё
Ключевые термины
PostToolUse— событие после успешного вызова инструмента. Отменить сам вызов оно не может: инструмент уже выполнен.- Матчер — фильтр по имени инструмента.
"Edit|Write"— точные имена через разделитель; любой другой символ включает режим регулярного выражения. - stdin хука — JSON события, который Claude Code передаёт скрипту на вход. Для инструментов содержит
tool_name,tool_input,tool_use_id. - Exit 2 — блокирующий код выхода. На
PostToolUseблокировать нечего, поэтому stderr показывается Claude как обратная связь. ${CLAUDE_PROJECT_DIR}— плейсхолдер корня проекта. В отличие отcwd, не смещается, когда Claude меняет директорию.
Почему просьба в CLAUDE.md не заменяет хук
Первое, что приходит в голову, — написать в CLAUDE.md строку «после каждой правки прогоняй eslint». Иногда это работает. Но CLAUDE.md — это контекст, текст, который модель лишь учитывает. В длинной сессии, под давлением основной задачи, инструкция теряется: одну правку Claude пролинтует, три следующие — нет.
Хук устроен иначе. Это не просьба, а обработчик, который Claude Code запускает сам в фиксированной точке жизненного цикла. Модель не решает, выполнять ли его. В этом и состоит детерминированный контроль: действие происходит всегда.
Первая версия: одна строка в settings.json
Начнём с минимума. Открываем .claude/settings.json и добавляем событие:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx eslint"
}
]
}
]
}
}
Разберём по частям. Матчер Edit|Write отбирает два инструмента правки файлов. Команда получает на stdin JSON события, jq -r достаёт поле tool_input.file_path без кавычек, а xargs подставляет путь аргументом в npx eslint.
Что вы увидите: ничего. Результат успешного хука в разговоре не показывается, а если eslint найдёт проблему и вернёт ненулевой код, в транскрипте появится лишь уведомление об ошибке хука. До модели список проблем не дойдёт.
Слабых мест здесь три: eslint запускается на любом файле, включая README.md; о неуспехе версия сообщает невнятно; развивать её негде, потому что в JSON нет ни комментариев, ни условий.
Выносим логику в скрипт
Всё, что сложнее одной команды, живёт в файле. Создаём .claude/hooks/eslint-check.sh.
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Правка без пути к файлу — выходим молча
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Не всякая правка — код: линтуем только JS и TS
case "$FILE_PATH" in
*.js|*.jsx|*.ts|*.tsx) ;;
*) exit 0 ;;
esac
OUTPUT=$(npx eslint "$FILE_PATH" 2>&1)
STATUS=$?
if [ $STATUS -ne 0 ]; then
echo "eslint нашёл проблемы в $FILE_PATH:" >&2
echo "$OUTPUT" >&2
exit 2
fi
exit 0
INPUT=$(cat) читает весь stdin в переменную: прочитать его нужно один раз, дальше обращаться к $INPUT. Модификатор // empty заменяет отсутствующее поле пустой строкой вместо литерального null, поэтому проверка -z срабатывает корректно.
Дальше два ранних выхода: на случай события без пути и фильтр по расширению, чтобы правка CHANGELOG.md не поднимала eslint. Оба выходят с кодом 0, штатно и молча.
Регистрируем скрипт вместо однострочника:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/eslint-check.sh",
"timeout": 60
}
]
}
]
}
}
Обратная связь модели: почему именно exit 2
Ключевая строка скрипта — exit 2 после записи вывода в stderr. Здесь легко ошибиться, поэтому проговорим прямо.
На PostToolUse инструмент уже выполнен. Файл записан на диск, отменить это хук не может — блокировать нечего. exit 2 делает здесь ровно одну вещь: передаёт содержимое stderr модели как обратную связь. Claude видит ошибки eslint, понимает, что правка не проходит проверку, и исправляет её следующим действием. Отката изменения не происходит.
Именно поэтому exit 1 не подойдёт. Любой ненулевой код, кроме двойки, трактуется как неблокирующая ошибка: работа продолжается, в транскрипте появляется уведомление <hook name> hook error и первая строка stderr с префиксом Failed with non-blocking status code:. Это сообщение для вас, а не для модели, и полного вывода линтера в нём нет.
Что вы увидите: Claude в следующем ходе перечитает файл, поправит проблемы и запишет его заново, а хук отработает снова, уже с кодом 0.
Проверяем результат и упираемся в ограничение
Скрипт нужно сделать исполняемым, иначе хук молча не сработает:
chmod +x .claude/hooks/eslint-check.sh
# Ручной прогон с примером входного JSON
echo '{"tool_name":"Edit","tool_input":{"file_path":"src/app.ts"}}' \
| .claude/hooks/eslint-check.sh
echo $?
# Проверка раннего выхода: не код, значит код возврата 0
echo '{"tool_input":{"file_path":"README.md"}}' | .claude/hooks/eslint-check.sh
echo $?
# Отладочный лог сессии
claude --debug-file /tmp/claude.log
tail -f /tmp/claude.log
Первый прогон на файле с ошибкой должен напечатать вывод eslint и вернуть 2. Второй — не напечатать ничего и вернуть 0. Если оба возвращают 127, не установлен jq. Список зарегистрированных хуков показывает команда /hooks, но только на чтение: править конфигурацию нужно в JSON.
Теперь про важное ограничение. Матчер Edit|Write ловит правки только соответствующих инструментов. Если Claude перепишет файл через Bash — командой sed -i или редиректом, — хук не сработает: имя инструмента будет Bash, матчер не совпадёт.
Полное покрытие достигается иначе. Можно добавить Stop-хук, который раз за ход сканирует рабочее дерево через git status --porcelain и линтует всё изменённое разом. Можно использовать событие FileChanged: оно реагирует на изменение отслеживаемого файла независимо от того, кто его записал, а матчер там — список имён файлов через |, трактуемый как литеральные имена, а не как регулярное выражение. Для повседневной работы Edit|Write обычно достаточно, но про дыру полезно помнить.
Частые ошибки
- Забыть
chmod +x. Самая частая причина «хук настроен, но ничего не происходит». Успешный хук в разговоре не отображается, поэтому неработающий выглядит так же. - Поставить
exit 1вместоexit 2. Вывод линтера до модели не дойдёт: код 1 — неблокирующая ошибка, вы увидите только уведомление и первую строку stderr. - Писать вывод eslint в stdout. На
PostToolUsestdout уходит в отладочный лог, а не в контекст модели. Обратную связь передаёт stderr вместе с кодом 2. - Линтовать всё подряд. Без фильтра по расширению eslint запускается на разметке и конфигах, тормозя правки.
Наиболее частые вопросы
- Хук откатит плохую правку? — Нет. Инструмент уже выполнен, изменение остаётся на диске.
exit 2только передаёт stderr модели, чтобы она исправила код. - Почему не
PreToolUse? — Там файла в новом виде ещё нет, линтовать нечего.PreToolUseуместен для запретов, а не для проверки результата. - Не зациклится ли это? — Правка после замечания снова поднимет хук, но уже на исправленном коде, и он вернёт 0. Цикл прерывается сам.
- Куда положить конфигурацию? — В
.claude/settings.json, если проверка нужна всей команде. В~/.claude/settings.json— если это ваша личная привычка во всех проектах. - Хук не сработал после правки
settings.json. — Обычно правки подхватывает файловый наблюдатель. Если ничего не изменилось, проверьте JSON на висящие запятые и перезапустите сессию.
Итоги
Вы прошли типовой путь боевого хука: собрали однострочник, упёрлись в его ограничения, вынесли логику в скрипт с фильтрацией и ранними выходами и довели до состояния, где ошибки линтера возвращаются модели и исправляются без вашего участия. Попутно разобрали самое неочевидное место темы — смысл exit 2 на событии, где блокировать нечего.
Тот же шаблон приложим к любой проверке с кодом возврата и внятным выводом: tsc --noEmit, prettier в режиме проверки, точечный запуск тестов по изменённому файлу. А про дыру с Bash помните: для серьёзной политики качества к Edit|Write придётся добавить проверку рабочего дерева раз за ход.