Chapter 03 · 3 min

Lab 2 — Hook

Fifteen minutes. One shell script. Exit code 2. By the end of this lab, Claude Code physically cannot write a barrel import into the starter repo.

Workshop chapter 3 of 6. SetupLab 1Lab 2 (you are here)Lab 3Lab 4Capstone.

Recap in 30 seconds. A Claude Code hook is a shell command wired to a lifecycle event. Before the agent edits a file (PreToolUse:Edit), Claude Code pipes a JSON payload to your script. If your script exits 2, the edit is rejected and the agent sees stderr as a retry signal. Deterministic, fast, no model in the loop. Deep dive: The Claude Hooks Lifecycle Primer.

Goal. Write a PreToolUse hook that refuses any Edit or Write containing a barrel import (from '@/components'). Trigger it intentionally. Watch the agent retry with a direct import.

Step 1 — Write the script

Create .claude/hooks/no-barrel-imports.sh in the starter repo:

#!/usr/bin/env bash
# .claude/hooks/no-barrel-imports.sh
# Reject any Edit/Write whose new content imports from the barrel.
set -euo pipefail
 
input=$(cat)
 
# tool_input.new_string covers Edit; tool_input.content covers Write.
content=$(jq -r '.tool_input.new_string // .tool_input.content // empty' <<<"$input")
file_path=$(jq -r '.tool_input.file_path // empty' <<<"$input")
 
# Skip non-TS/TSX writes so this hook stays scoped.
case "$file_path" in
  *.ts|*.tsx) ;;
  *) exit 0 ;;
esac
 
if grep -qE "from ['\"]@/components['\"]" <<<"$content"; then
  cat >&2 <<'MSG'
Barrel imports from "@/components" are banned. Import each component directly:
 
  ✗  import { Button, Card } from "@/components";
  ✓  import { Button } from "@/components/Button";
  ✓  import { Card } from "@/components/Card";
 
Reason: barrel files break tree-shaking and obscure the dependency graph at PR-review time.
MSG
  exit 2
fi

Make it executable:

chmod +x .claude/hooks/no-barrel-imports.sh

The script does three things: parse the JSON payload, narrow to TS/TSX files, grep for the banned pattern. Everything else is the message you want the agent (and the next teammate) to read.

Step 2 — Wire it into settings

Open .claude/settings.json and add the hook to both Edit and Write:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/no-barrel-imports.sh"
          }
        ]
      }
    ]
  }
}

The matcher is a regex over tool names. Edit|Write covers both surgical edits and full-file writes. The hook lives at the repo root via $CLAUDE_PROJECT_DIR so it works from any subdirectory.

Restart Claude Code in the repo so the new settings load.

Step 3 — Trigger it

In Claude Code, ask:

Add a new Badge component at src/components/Badge.tsx and import it in App.tsx alongside Button and Card.

Watch the agent try to extend the existing barrel import:

import { Button, Card, Badge } from "@/components";

The hook fires. Exit code 2. Stderr message shows up in the transcript. The agent reads the rejection, rewrites with three direct imports, retries — the second write passes the hook and lands on disk.

You just enforced a rule that CLAUDE.md had been failing to enforce.

What this hook can and cannot do

Can: stop a known-bad string before it hits disk. Run in milliseconds. Compose with every other PreToolUse:Edit hook in your settings.

Cannot: decide whether an existing barrel is one the team wants to keep (some are deliberate). Cannot tell you how many barrels already live in the codebase. Cannot rewrite the offending file for you.

Those are jobs for the next two layers.

Optional: tighten the match

The grep above catches from '@/components' and from "@/components". To catch deeper barrels (@/components/forms, @/lib/utils), extend the regex:

if grep -qE "from ['\"]@/(components|lib|hooks)(/index)?['\"]" <<<"$content"; then

Resist the urge to make the hook clever. Hooks are at their best when they catch one specific shape very fast. If a rule needs three regexes and a state machine, it’s a subagent in disguise.

What’s next

The hook blocks new barrels. It doesn’t tell you about the ones already in the repo, and it doesn’t have an opinion about which direct import is the right replacement. That’s Lab 2 — Subagent: a read-only auditor that returns a table of remaining barrels with concrete fix suggestions.

0:00 0:00