$ sudo teach IT

Линтер обычно запускается позже, чем хотелось бы: на 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. На PostToolUse stdout уходит в отладочный лог, а не в контекст модели. Обратную связь передаёт 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 придётся добавить проверку рабочего дерева раз за ход.