Keyword: HOOKS
From @learningwithcole
Hooks are small scripts Claude Code runs on its own at set moments. Unlike a line in CLAUDE.md, Claude can't forget or skip them. This guide sets up three:
- PreToolUse runs before a tool call. Exit code 2 blocks the call, and Claude gets your message as feedback.
- PostToolUse runs after a tool call succeeds.
- Stop runs when Claude finishes responding (not when you interrupt it).
You need jq for these scripts: brew install jq on macOS, apt-get install jq on Debian or Ubuntu.
1. The settings file
Put this in .claude/settings.json in your project root. That file is for this project only, and you can commit it so your team gets the same rules.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log.sh" }
]
}
]
}
}
Already have a .claude/settings.json? Add the hooks block next to what's there.
2. The three scripts
Save each one in .claude/hooks/.
guard.sh (PreToolUse): blocks edits to .env files and a few dangerous commands.
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [[ "$FILE_PATH" == *".env"* ]]; then
echo "Blocked: $FILE_PATH is a protected .env file" >&2
exit 2
fi
for pattern in "rm -rf" "git push --force" "git reset --hard"; do
if [[ "$COMMAND" == *"$pattern"* ]]; then
echo "Blocked: '$pattern' is not allowed in this project" >&2
exit 2
fi
done
exit 0
format.sh (PostToolUse): runs Prettier on the file Claude just edited. Swap in your own formatter (black, gofmt, rustfmt) if you don't use Prettier.
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
[ -n "$FILE_PATH" ] && npx prettier --write "$FILE_PATH" >/dev/null 2>&1
exit 0
log.sh (Stop): adds a short entry to .claude/session.log each time Claude finishes: the time, a one-line git diff summary, and the start of Claude's last reply.
#!/bin/bash
INPUT=$(cat)
# Skip if Claude is already continuing because of a Stop hook
[ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ] && exit 0
{
echo "## $(date '+%Y-%m-%d %H:%M')"
git -C "$CLAUDE_PROJECT_DIR" diff --stat 2>/dev/null | tail -n 1
echo "$INPUT" | jq -r '.last_assistant_message // empty' | head -c 400
echo
echo
} >> "$CLAUDE_PROJECT_DIR/.claude/session.log"
exit 0
Add .claude/session.log to your .gitignore if you don't want it in the repo.
Then make all three executable (hooks won't run otherwise):
chmod +x .claude/hooks/guard.sh .claude/hooks/format.sh .claude/hooks/log.sh
3. Test it
- Start Claude Code in the project and type
/hooks. You should see one hook each under PreToolUse, PostToolUse and Stop. - Ask Claude: "add a comment to my .env file". The edit is blocked before it runs, and Claude sees "Blocked: … is a protected .env file".
- Ask Claude to change a line in any JS or TS file, then open it. Prettier has reformatted it.
- Open
.claude/session.log. There's an entry for each reply.
Good to know
- A hook can't loosen your permission rules, and a block holds in every permission mode.
- The Bash check is a pattern match, so it's a guard rail, not a wall. For a hard "never run this", also add a deny rule in your permissions.
- To turn all hooks off for a while without deleting them, add
"disableAllHooks": trueto the settings file.
That’s the whole guide.
Want the next tool when it posts?
Get new guides by email. The guides stay free here either way.