Pular para o conteúdo

Builder OS

Builder · OS
L02 · Hooks como alavanca
~14 MIN DE LEITURA

Lição 2 de 6: Hooks como alavanca

lição 2/6 do Módulo 5
AO FIM, VOCÊ VAI TER
  • Hook Stop configurado: chama bin/hooks/run-check-if-changed.sh, que roda npm run check ao fim de toda sessão que modificou src/
  • Hook PreToolUse configurado: chama bin/hooks/block-env-read.sh e bin/hooks/block-env-bash.sh, que bloqueiam leitura de .env* pelo Claude
  • Os 2 hooks testados em prática (sessão real de teste)
  • Commit feat(hooks): stop check + preventool env block

é automação que roda sem você pedir.

A alavanca que você ainda não tocou

Você usou skills (M3/L11), plans (M2), rules (M1). Hooks é o pedaço que faltou, e é a alavanca de maior retorno do Claude Code pra um builder solo.

Hook é um script que roda automaticamente em momentos específicos: Stop (Claude termina de responder), PreToolUse (antes de usar uma ferramenta tipo Bash ou Read), PostToolUse (depois). Você define o trigger, o filtro e o comando. O Claude nunca esquece de rodar, e você não precisa pedir.

1. Trigger: momento do Claude (Stop, PreToolUse, PostToolUse) dispara o hook.
2. Filtro: matcher + condição shell (ex: git diff --quiet) decide se o hook deve rodar pra este caso.
3. Ação: comando executa. exit 0 deixa passar; bloqueia a ferramenta e mostra mensagem.

Esta lição instala 2 hooks que todo projeto deveria ter:

  1. Hook Stop: toda vez que Claude termina e você mexeu no código, roda npm run check (lint + typecheck) automático. Pega erro antes de você fazer commit.
  2. Hook PreToolUse: toda vez que Claude tenta ler .env* pelas vias comuns (Read, cat/head/less), o sistema bloqueia. Pega os caminhos mais prováveis mesmo quando você esquece. Não é blindagem total (veja o caveat no Passo 3).

Pré-requisitos desta lição:

  • : usado pra parsear o input do hook. Confere com which jq. Se não tem: brew install jq (macOS), sudo apt-get install jq (Linux), ou stedolan.github.io/jq (Windows). Sem jq, hooks com filtro quebram silenciosamente.
  • Os blocos de JSON nesta lição mostram o conteúdo já renderizado pra colar em .claude/settings.json. Se você está vendo o source MDX do site, vai notar escapes adicionais (\\"): eles são da template literal do MDX, não do JSON final. Copia o que aparece no bloco renderizado, não o que aparece no source do MDX.

Vocabulário rápido:

  • Hook = arquivo de configuração (em .claude/settings.json ou .claude/settings.local.json) que define quando rodar um comando shell. Não é skill: skill descreve workflow pra Claude; hook é gatilho que executa fora do controle do Claude.
  • Stop hook = roda quando Claude termina de responder ao usuário. Caso de uso clássico: rodar tests/lint depois de qualquer edit, automaticamente, sem o usuário pedir.
  • PreToolUse hook = roda antes de uma ferramenta executar. Caso de uso: blocking. Se o comando vai ler .env.local, hook retorna exit code 2 e ferramenta é bloqueada.
  • PostToolUse hook = roda depois. Caso de uso: logging, ou rodar um lint específico após cada Edit.

Por que hooks valem mais que skills (às vezes)

Skill é convidativa: você descreveu o workflow, e o Claude pode invocar ou não, baseado em heurística. Hook é compulsório: o trigger acontece, o hook roda. Sem espaço pra Claude esquecer.

Pra coisas que não podem variar (segurança: nunca ler .env*; qualidade: sempre rodar lint), hook é melhor que skill. Pra coisas que dependem de contexto (quando criar PR, qual estrutura de plano), skill é melhor: o julgamento do Claude é o valor.

Critério prático: se você descobre que está pedindo a mesma coisa 3 vezes ("roda lint antes de terminar", "não lê o .env"), vira hook. Se é workflow flexível com decisões, vira skill.

Passo 1 — Instale o Stop hook (lint automático)

A estratégia em todos os hooks desta lição: o JSON em .claude/settings.json só diz quando rodar; a lógica vive num script em bin/hooks/. Por quê? JSON com shell inline (INPUT=$(cat); FILE=$(echo ... | jq -r ...); if grep -qE ...) até cola, mas você não consegue debugar: se quebrar, não tem onde abrir. Já um script separado tem cabeçalho de comentários, você lê, ajusta e testa direto.

Cole no Claude:

prompt · text
Cria/atualiza `.claude/settings.json` com um hook `Stop` que delega pra `bin/hooks/run-check-if-changed.sh`:

{
"hooks": {
  "Stop": [
    {
      "matcher": "",
      "hooks": [
        {
          "type": "command",
          "command": "bash bin/hooks/run-check-if-changed.sh"
        }
      ]
    }
  ]
}
}

Depois cria `bin/hooks/run-check-if-changed.sh` com o conteúdo:

#!/usr/bin/env bash
# bin/hooks/run-check-if-changed.sh
#
# Roda no hook `Stop` quando Claude termina de responder. Se houve
# mudança em código (src/ ou app/) durante a sessão, dispara
# `npm run check`. Se nada mudou, sai limpo (exit 0).
#
# Entrada: nenhuma (hook Stop não passa payload relevante).
# Saída:   exit 0 se nada mudou; senão delega pro exit code de
#          `npm run check` (lint + typecheck).
set -euo pipefail

if git diff --quiet HEAD -- 'src/' 'app/' 2>/dev/null; then
exit 0
fi

npm run check

E roda `chmod +x bin/hooks/run-check-if-changed.sh` pra deixar executável.

Mostre o diff antes de criar.

Aprove. Confira que .claude/settings.json (não .local.json) tem a entrada: você quer que isso vá pro git, pro time inteiro pegar. Confira também que bin/hooks/run-check-if-changed.sh existe e tem permissão de execução (ls -la bin/hooks/).

Passo 2 — Teste o Stop hook

Faz uma sessão curta de teste:

prompt · text
Teste do hook Stop:

1. Faz uma edit pequena em algum arquivo `src/` (qualquer adição inócua: um comentário, um espaço).
2. Termina sua resposta sem rodar lint.
3. Espera o hook disparar.

Quero ver `npm run check` rodando automaticamente depois que você terminar de responder. Se passar verde, ótimo. Se falhar, vai mostrar o erro e a próxima sessão eu sei que tem algo pra consertar.

Aprove. Confirma no terminal: depois da resposta do Claude, deve aparecer output do npm run check rodando. Se sim, hook está armado.

Passo 3 — Instale o PreToolUse hook (bloquear .env*)

Este é o de segurança. Você não quer Claude lendo arquivos com segredos, mesmo acidental, mesmo "só pra verificar."

Mesma estratégia do Passo 1: o JSON só aponta pro script; a lógica de filtro fica em bin/hooks/. Aqui o ganho é maior: o filtro usa jq e grep -qE com regex de .env*, exatamente o tipo de coisa que quebra em silêncio quando vive inline no JSON.

prompt · text
Adiciona ao `.claude/settings.json` um hook PreToolUse que bloqueia leitura de `.env*` (exceto `.env.example`), delegando pra scripts em `bin/hooks/`:

{
"hooks": {
  "Stop": [...],
  "PreToolUse": [
    {
      "matcher": "Read",
      "hooks": [
        {
          "type": "command",
          "command": "bash bin/hooks/block-env-read.sh"
        }
      ]
    },
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "command",
          "command": "bash bin/hooks/block-env-bash.sh"
        }
      ]
    }
  ]
}
}

Depois cria `bin/hooks/block-env-read.sh`:

#!/usr/bin/env bash
# bin/hooks/block-env-read.sh
#
# Roda no hook `PreToolUse` com matcher `Read`. Lê o JSON do hook em
# stdin, extrai o `file_path` que Claude quer ler, e bloqueia se for
# um arquivo `.env*` real. Deixa passar `.env.example` (sem segredo).
#
# Entrada: stdin é JSON do hook
#          (`{"tool_input":{"file_path":"..."}}`).
# Saída:   exit 0 deixa passar; exit 2 bloqueia (Claude desiste).
#
# Pra debugar:
#   echo '{"tool_input":{"file_path":".env.local"}}' \
#     | bash bin/hooks/block-env-read.sh
set -euo pipefail

INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')

if [[ "$FILE" == *".env.example" ]]; then
exit 0
fi

if echo "$FILE" | grep -qE '\.env(\.(local|production|development))?$'; then
echo "BLOCKED: Cannot read .env files (use .env.example instead)." >&2
exit 2
fi

exit 0

E `bin/hooks/block-env-bash.sh`:

#!/usr/bin/env bash
# bin/hooks/block-env-bash.sh
#
# Roda no hook `PreToolUse` com matcher `Bash`. Bloqueia se o
# comando tenta `cat`/`head`/`less` num arquivo `.env`. Complementa
# `block-env-read.sh` — sem este, Claude foge via shell.
#
# Entrada: stdin é JSON do hook
#          (`{"tool_input":{"command":"..."}}`).
# Saída:   exit 0 deixa passar; exit 2 bloqueia.
set -euo pipefail

INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

if echo "$CMD" | grep -qE '(cat|head|less) [^|;&]*\.env(\.|$| )'; then
echo "BLOCKED: Cannot cat/head/less .env files." >&2
exit 2
fi

exit 0

E roda `chmod +x bin/hooks/*.sh` no final.

Mostre o diff antes de criar.

Aprove. Importante: se o sistema não tem jq instalado, o script quebra (jq: command not found em stderr). Confirma com which jq. Se não tem, instala (brew install jq no macOS). A vantagem de ter o filtro em script: se quebrar, você abre bin/hooks/block-env-read.sh, lê o cabeçalho, roda o exemplo de debug e descobre na hora se é jq faltando, regex errada ou JSON mal formado.

Passo 4 — Teste o PreToolUse hook

prompt · text
Teste do hook PreToolUse: tenta ler `.env.local` (Read tool). Sem bypass, só tenta direto.

Espero que você receba "BLOCKED: Cannot read .env files" e não consiga acessar o conteúdo. Se conseguir ler, o hook está mal configurado e a gente conserta.

Aprove. Claude vai tentar Read .env.local, vai receber exit code 2 + mensagem, e vai responder algo como "Não consegui ler, bloqueado por hook." Se passou, segurança está armada.

Pra confirmar o bypass do .env.example:

prompt · text
Agora lê `.env.example` (não `.env.local`). Espero que funcione: o hook só bloqueia os .env reais, não o exemplo.

Aprove. Se Claude lê normalmente, o filtro está correto.

Passo 5 — Faça o commit

prompt · text
Faça commit do `.claude/settings.json` atualizado com os 2 hooks. Mensagem: `feat(hooks): stop check + preventool env block`. Mostra os comandos antes.

Aprove. Confira no GitHub que .claude/settings.json tem os hooks (deve estar versionado), e .claude/settings.local.json não está (deve estar gitignored, como você verificou no M1).

Build Diary — antes dos hooks, o .local.json do CEAP tinha 96 entries

Trecho do settings.local.json do CEAP (não versionado, mas o autor compartilhou em retrospectiva):

96 entries em permissions.allow: comandos pré-aprovados pra Claude não pedir confirmação. Inclui coisas como Bash(cd dashboard && npm run dev), Bash(python notebooks/...), Bash(npm run build), etc.

Por que tantos? Porque sem hook, cada operação que o autor queria automatizar virou uma entrada no allowlist: ele "delegava" pra Claude rodar e confiava no allowlist como camada de segurança.

Depois de instalar Stop → npm run check como hook, metade dessas entries virou dispensável. A permissão até continuava fazendo sentido, mas a operação que ela autorizava não precisa mais ser pedida: o hook roda sem pedir.

No geral, hooks reduzem o tamanho do permissions.allow. Cada vez que você está prestes a adicionar nova entrada, pergunta: "isso deveria virar hook?" Se sim (repetitivo + universal), hook resolve melhor. Se é caso específico (rodar python scripts/data-prep.py raramente), allowlist é o caminho.

Takeaways

  • Hook roda sempre que o trigger acontece; skill o Claude invoca ou não, por julgamento. Pra coisas que não podem variar (segurança, qualidade), hook ganha; pra workflow flexível com julgamento, skill ganha.
  • 2 hooks universais que todo projeto deveria ter: Stop → npm run check (qualidade) + PreToolUse → bloquear .env* (segurança).
  • Exit code 2 do hook bloqueia a ferramenta. Sem isso, hook é só observação: Claude continua e usa.
  • jq é dependência implícita. Sem jq, hook com filtro quebra silencioso. Confere which jq antes de instalar hook complexo.

Você terminou quando

Quatro coisas:

  1. .claude/settings.json tem hooks Stop e PreToolUse configurados
  2. Stop hook testado: edit + resposta → npm run check rodou automático
  3. PreToolUse hook testado: Read .env.local foi bloqueado; Read .env.example funcionou
  4. Tudo commitado