Mike Barkas

Software and DevOps

Mike Barkas

Mike Barkas

Software and DevOps

Claude Code Hooks and Skills

October 20, 2026

In the last article I set up CLAUDE.md and permissions for Claude Code with setting.json.

This one covers the next two tools in the box:

  • Hooks are things that happen every time, automatically.
  • Skills are procedures I want done the same way every time I ask.

Hooks

A hook is my shell command, run by Claude Code (not by the model) when an event fires. The model can’t skip it, forget it, or talk its way around it.

The events I care about:

EventFiresCan block?
PreToolUseBefore a tool runsYes
PostToolUseAfter a tool succeedsNo
StopWhen Claude finishesCan make it keep going
SessionStartSession opensNo

The contract is simple:

  • Input: JSON on stdin. tool_input.file_path for edits, tool_input.command for Bash. Parse it with jq.
  • Output: the exit code. 0 is fine. 2 blocks the action, and whatever you print to stderr is sent to Claude as the reason.

Hook 1: Block .env in shell commands

This closes the gap from last time:

#!/usr/bin/env bash
# .claude/hooks/block-env.sh
# PreToolUse (Bash): refuse shell commands that mention .env files.
set -euo pipefail

cmd=$(jq -r '.tool_input.command // empty')

if printf '%s' "$cmd" | sed 's/\.env\.example//g' | grep -q '\.env'; then
  echo "Blocked by hook: commands that touch .env files are not allowed. Use .env.example." >&2
  exit 2
fi

To be honest, this is string matching. It stops accidents, not a determined attacker (cat .e* walks right past it). The real fix is not keeping secrets where they don’t need to be.

It’s also strict. Right after I turned it on, it blocked Claude’s own find . -name '.env*', which was only checking whether a secret file existed. Later it blocked writing a .dockerignore, because the file contains .env to keep secrets out of the image. Claude used the Write tool instead and told me why.

I kept it strict anyway. A false positive costs one rephrased command. A false negative leaks a secret.

Hook 2: Format every file Claude edits

A PR once failed CI on prettier --check because of a single trailing space in a README. One space. My editor formats on save for me, so this hook does the same for Claude:

#!/usr/bin/env bash
# .claude/hooks/format.sh
# PostToolUse (Edit|Write): run Prettier on the file Claude just changed.
set -euo pipefail

file=$(jq -r '.tool_input.file_path // empty')
[ -n "$file" ] || exit 0

cd "$CLAUDE_PROJECT_DIR"
node_modules/.bin/prettier --write --ignore-unknown "$file" >/dev/null

Wiring them up

Both go in .claude/settings.json:

"hooks": {
  "PreToolUse": [
    { "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-env.sh" }] }
  ],
  "PostToolUse": [
    { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh" }] }
  ]
}

matcher is a regex on the tool name.

The hook that did nothing

I tested the format hook by having Claude write a badly formatted .ts file. The file came out unchanged, and there was no error anywhere. Great.

Running the script by hand with the same JSON showed the problem:

env: node: No such file or directory

I manage Node with fnm, which is set up in ~/.bash_profile. Only login shells read that file. Hooks don’t run in a login shell, so node wasn’t on the PATH. Any exit code other than 0 or 2 is a non-blocking error, so the edit went through unformatted and nobody complained.

The fix is to load fnm in the hook if needed, and fail loudly if Node still isn’t there:

if ! command -v node >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then
  eval "$(fnm env --shell bash)"
fi
if ! command -v node >/dev/null 2>&1; then
  echo "format hook: node not found on PATH; file not formatted: $file" >&2
  exit 1
fi

Lessons:

  • Hooks don’t run in the same environment as your terminal.

  • Test a hook by piping JSON into it with a minimal environment:

    echo '{"tool_input":{"file_path":"src/app.ts"}}' \
      | env -i HOME=$HOME PATH=/usr/bin:/bin CLAUDE_PROJECT_DIR=$PWD .claude/hooks/format.sh
    
  • A guardrail that silently does nothing is worse than no guardrail, because you think you’re protected.

One more thing: hooks run automatically, with your user’s permissions. A repo you clone can ship hooks in its committed .claude/settings.json. Read them before you trust a repo, the same way you would npm install scripts.

Skills

A skill is a reusable procedure in a folder: .claude/skills/<name>/SKILL.md. It has YAML frontmatter with a name and description, then Markdown instructions. It can include scripts or templates too.

At the start of a session, Claude Code only loads each skill’s name and description. The full instructions load when the skill is used. So you can have plenty of skills without filling up the context.

That’s also the rule of thumb for CLAUDE.md vs. skills:

  • CLAUDE.md: facts and rules that always apply.
  • Skills: procedures that only matter sometimes.

You run a skill by typing /name, or Claude picks one when the description fits. For anything with side effects, like creating branches, I turn off automatic use so only I can trigger it.

/start-issue

Every issue I work on starts the same way:

  1. Read the GitHub issue.
  2. Make sure the working tree is clean.
  3. Update dev and create a branch named <issue#>--short-slug.
  4. Plan before writing code.

So that’s a skill:

---
name: start-issue
description: "Start work on a GitHub issue in this repo: read it, create the branch, and plan before coding."
disable-model-invocation: true
---

1. Read the issue with `gh issue view <number>`.
2. Run `git status --short`. If anything is uncommitted, list it and stop.
3. Update `dev` and create a branch named `<number>--<short-slug>`.
4. Gather context, write a plan, and wait for my approval before writing code.

Now /start-issue 3 does all of that.

Testing it

I ran /start-issue 3 on purpose with uncommitted work in the tree. The skill read the issue, ran git status, listed the four uncommitted files, and stopped. No stash, no commit, no branch switch. A procedure that stops and tells you beats one that “helpfully” tidies up your work.

With a clean tree, it created 3--fix-bug working branch and gathered context. Then it stopped with a plan, as it was told to.

Summary

  • Hooks are for things that must happen every time: blocking, formatting, checking. Exit 2 to block. Test them in a clean environment, and make them fail loudly.
  • Skills are for procedures you repeat. Keep the description short, quote it, and turn off automatic use for anything with side effects.
  • Both can fail silently. Check that they actually ran before you trust them.

CLAUDE.md asks nicely. Settings and hooks don’t ask.