Lição 2 de 6: Hooks como alavanca
- Hook
Stopconfigurado: chamabin/hooks/run-check-if-changed.sh, que rodanpm run checkao fim de toda sessão que modificousrc/ - Hook
PreToolUseconfigurado: chamabin/hooks/block-env-read.shebin/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.
Stop, PreToolUse, PostToolUse) dispara o hook.matcher + condição shell (ex: git diff --quiet) decide se o hook deve rodar pra este caso.exit 0 deixa passar; bloqueia a ferramenta e mostra mensagem.Esta lição instala 2 hooks que todo projeto deveria ter:
- Hook
Stop: toda vez que Claude termina e você mexeu no código, rodanpm run check(lint + typecheck) automático. Pega erro antes de você fazer commit. - 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). Semjq, 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.jsonou.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.Stophook = 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.PreToolUsehook = 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.PostToolUsehook = roda depois. Caso de uso: logging, ou rodar um lint específico após cadaEdit.
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:
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:
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.
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
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:
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
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 comoBash(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. Semjq, hook com filtro quebra silencioso. Conferewhich jqantes de instalar hook complexo.
Você terminou quando
Quatro coisas:
.claude/settings.jsontem hooksStopePreToolUseconfiguradosStophook testado: edit + resposta →npm run checkrodou automáticoPreToolUsehook testado:Read .env.localfoi bloqueado;Read .env.examplefuncionou- Tudo commitado