DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Claude Code Hooks: Automate Code Review Before Every Commit

Claude Code Hooks: Automate Code Review Before Every Commit

Claude Code ships with a hooks system that most developers ignore entirely. It lets you run arbitrary shell commands at defined points in Claude's execution lifecycle — before a tool runs, after it completes, when a session turn ends, and when a desktop notification fires.

The practical payoff: enforce code quality rules at the execution layer so they run every time, automatically, without relying on prompts or memory. This article covers the three hooks you'll actually use, complete settings.json configuration, and working scripts for linting, secret scanning, and TypeScript verification.

The Hooks Lifecycle

Claude Code exposes four hook points:

  • PreToolUse — fires before Claude invokes a tool. Can block the tool call by exiting non-zero.
  • PostToolUse — fires after a tool completes. Output becomes context Claude reads on its next turn.
  • Stop — fires when Claude finishes a response turn. Useful for full-sweep reviews after edits.
  • Notification — fires when Claude would send a desktop notification.

Hooks receive context from Claude via stdin as JSON. They can return information to Claude via stdout. A non-zero exit from a PreToolUse hook blocks the tool call and surfaces your stderr message as context for Claude to self-correct.

The most powerful patterns: use PreToolUse on Write to block secrets from hitting disk, use PostToolUse on Edit to run lint immediately while Claude still has context to fix errors, and use Stop to run a full type-check sweep after every turn.

Configuration: settings.json

Hooks live in .claude/settings.json at your project root, or ~/.claude/settings.json for global hooks that apply to every project. Project-level hooks take precedence over global ones.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/.claude/hooks/secret-scan.js"
          }
        ]
      },
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/.claude/hooks/secret-scan.js"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node /Users/you/.claude/hooks/lint-on-edit.js"
          }
        ]
      },
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "node /Users/you/.claude/hooks/lint-on-edit.js"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node /Users/you/.claude/hooks/stop-review.js"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The matcher field accepts the exact tool name: Bash, Edit, Write, Read, Glob, Grep. Stop hooks don't take a matcher since they fire at the session turn level, not per-tool.

Use absolute paths in hook commands. Claude Code runs hooks in a subshell where ~ expansion depends on shell configuration and isn't guaranteed. $HOME works reliably, or hardcode the path.

Hook Input Format

Every hook receives a JSON payload on stdin. The shape varies by lifecycle stage:

// PreToolUse and PostToolUse — what you receive on stdin
interface ToolHookInput {
  session_id: string;
  tool_name: string;           // 'Bash', 'Edit', 'Write', 'Read', etc.
  tool_input: Record<string, unknown>; // arguments passed to the tool
}

// PostToolUse also includes the tool's response
interface PostToolHookInput extends ToolHookInput {
  tool_response: {
    output?: string;           // stdout from Bash, content from Read, etc.
    error?: string;            // error message if the tool failed
  };
}

// Stop hook input
interface StopHookInput {
  session_id: string;
  stop_hook_active: boolean;   // true if Stop hooks are actively running
}
Enter fullscreen mode Exit fullscreen mode

For a Write tool call, tool_input looks like:

{
  "file_path": "/src/api/users.ts",
  "content": "import { z } from 'zod';\n..."
}
Enter fullscreen mode Exit fullscreen mode

For Edit: { file_path, old_string, new_string }. For Bash: { command, timeout }. You have full access to what Claude is about to do before it happens.

Hook 1: Lint on Every Edit

This fires after every Edit or Write call. It runs ESLint on the modified file and returns the output to Claude as context on its next turn. Claude reads the lint errors and fixes them without you having to ask.

#!/usr/bin/env node
// ~/.claude/hooks/lint-on-edit.js

import { execSync } from 'child_process';
import { readFileSync } from 'fs';

const input = JSON.parse(readFileSync('/dev/stdin', 'utf-8'));
const { file_path } = input.tool_input;

// Only run ESLint on JS/TS files
if (!file_path || !/\.(ts|tsx|js|jsx|mts|mjs|cts|cjs)$/.test(file_path)) {
  process.exit(0);
}

// Skip generated files and node_modules
if (file_path.includes('node_modules') || file_path.includes('.gen.ts')) {
  process.exit(0);
}

try {
  // Use --format compact for concise output — Claude doesn't need full verbose format
  const output = execSync(
    `npx eslint --format compact "${file_path}" 2>&1`,
    {
      encoding: 'utf-8',
      cwd: process.env.PWD,
    }
  );

  if (output.trim()) {
    console.log(`ESLint: ${file_path}\n${output}`);
  }
} catch (err) {
  // ESLint exits 1 on lint errors, 2 on config errors
  const lintOutput = err.stdout || err.message;
  console.log(`ESLint errors in ${file_path}:\n${lintOutput}`);
}

// Always exit 0 from PostToolUse — non-zero here creates noise, not blocking
process.exit(0);
Enter fullscreen mode Exit fullscreen mode

The exit code semantics matter here. PostToolUse hooks run after the tool already executed — exiting non-zero doesn't undo the edit. Reserve non-zero exits for PreToolUse when you want to actually prevent something. A PostToolUse hook that exits non-zero just adds a confusing error to the session.

Claude reads the lint output as context for its next response. If ESLint reports 'foo' is never read no-unused-vars, Claude will clean it up. This feedback loop works because the output goes into the conversation context, not just your terminal.

Hook 2: Secret Scanning Before Write

This fires before Claude writes or edits any file. It scans the content for patterns that look like real credentials and blocks the write if it finds them, giving Claude a chance to move values to environment variables instead.

#!/usr/bin/env node
// ~/.claude/hooks/secret-scan.js

import { readFileSync } from 'fs';

const input = JSON.parse(readFileSync('/dev/stdin', 'utf-8'));
const { file_path, content, new_string } = input.tool_input;

// For Write: check 'content'. For Edit: check 'new_string'
const textToScan = content || new_string || '';

if (!textToScan) process.exit(0);

// Skip expected locations for secrets
const EXEMPT_PATHS = [
  '.env',
  '.env.local',
  '.env.example',
  '.env.test',
  'fixtures/',
  '__mocks__/',
  '__tests__/',
  '.gitignore',
];
if (file_path && EXEMPT_PATHS.some((p) => file_path.includes(p))) {
  process.exit(0);
}

const SECRET_PATTERNS = [
  { name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ },
  { name: 'Stripe Live Secret Key', pattern: /sk_live_[0-9a-zA-Z]{24,}/ },
  { name: 'Stripe Test Secret Key', pattern: /sk_test_[0-9a-zA-Z]{24,}/ },
  { name: 'OpenAI API Key', pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
  { name: 'Anthropic API Key', pattern: /sk-ant-api03-[a-zA-Z0-9\-_]{93}/ },
  { name: 'GitHub Personal Access Token', pattern: /ghp_[a-zA-Z0-9]{36}/ },
  { name: 'GitHub Fine-Grained PAT', pattern: /github_pat_[a-zA-Z0-9_]{82}/ },
  { name: 'Slack Bot Token', pattern: /xoxb-[0-9]{11}-[0-9]{11}-[a-zA-Z0-9]{24}/ },
  { name: 'Twilio Auth Token', pattern: /[0-9a-f]{32}/ }, // broad — check context
];

const PLACEHOLDER_PATTERNS = /your[-_]|<[^>]+>|example|placeholder|xxxx|\$\{|process\.env/i;

const found: string[] = [];
for (const { name, pattern } of SECRET_PATTERNS) {
  const match = textToScan.match(pattern)?.[0];
  if (match && !PLACEHOLDER_PATTERNS.test(match)) {
    found.push(name);
  }
}

if (found.length > 0) {
  process.stderr.write(
    `[SECRET SCAN BLOCKED] Possible credentials detected in ${file_path ?? 'content'}:\n` +
    found.map((f) => `  - ${f}`).join('\n') +
    `\n\nMove these values to environment variables (process.env.XXX) before writing.\n`
  );
  process.exit(2); // Non-zero exit from PreToolUse blocks the tool call
}

process.exit(0);
Enter fullscreen mode Exit fullscreen mode

Exiting with code 2 from a PreToolUse hook stops the tool and surfaces your stderr message as context in Claude's next turn. Claude reads the message and typically rewrites the content to use process.env references instead.

The PLACEHOLDER_PATTERNS check prevents false positives on template code like sk_live_YOUR_KEY_HERE or process.env.STRIPE_SECRET_KEY — which look like secrets structurally but aren't.

Hook 3: TypeScript Type-Check on Stop

This fires at the end of every session turn. After Claude finishes making changes, this runs tsc --noEmit and checks which modified files are missing test coverage. The report goes back into context for Claude's next turn.

#!/usr/bin/env node
// ~/.claude/hooks/stop-review.js

import { execSync } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { basename, dirname, join } from 'path';

const cwd = process.env.PWD;
const report = [];

// --- 1. TypeScript type check ---
try {
  execSync('npx tsc --noEmit 2>&1', { encoding: 'utf-8', cwd, stdio: 'pipe' });
  report.push('TypeScript: no errors');
} catch (err) {
  const output = (err.stdout || '').trim();
  if (output) {
    // Truncate to avoid flooding context
    const lines = output.split('\n').slice(0, 30);
    report.push(`TypeScript errors:\n\`\`\`\n${lines.join('\n')}\n\`\`\``);
  }
}

// --- 2. Check for missing test files on recently modified files ---
let changedFiles = [];
try {
  const gitDiff = execSync('git diff --name-only HEAD 2>/dev/null', {
    encoding: 'utf-8',
    cwd,
  });
  const gitStatus = execSync('git status --short 2>/dev/null', {
    encoding: 'utf-8',
    cwd,
  });

  const allChanged = [...gitDiff.split('\n'), ...gitStatus.split('\n')]
    .map((l) => l.replace(/^[?MAD ]+/, '').trim())
    .filter((f) => /\.(ts|tsx)$/.test(f))
    .filter((f) => !f.includes('.test.') && !f.includes('.spec.'))
    .filter((f) => !f.includes('node_modules'));

  changedFiles = [...new Set(allChanged)];
} catch {
  // Git not available or clean working tree
}

const missingTests = [];
for (const file of changedFiles) {
  const dir = dirname(file);
  const base = basename(file).replace(/\.tsx?$/, '');
  const candidates = [
    join(cwd, dir, `${base}.test.ts`),
    join(cwd, dir, `${base}.test.tsx`),
    join(cwd, dir, `${base}.spec.ts`),
    join(cwd, dir, '__tests__', `${base}.test.ts`),
  ];
  if (!candidates.some((p) => existsSync(p))) {
    missingTests.push(file);
  }
}

if (missingTests.length > 0) {
  report.push(
    `Missing test files for:\n${missingTests.map((f) => `- ${f}`).join('\n')}`
  );
}

// --- 3. Check for leftover debug code ---
try {
  const debugPatterns = execSync(
    `git diff HEAD 2>/dev/null | grep '^+' | grep -E '(console\.log|debugger|TODO:|FIXME:|HACK:)' | grep -v '\`\`\`' | head -10`,
    { encoding: 'utf-8', cwd }
  ).trim();

  if (debugPatterns) {
    report.push(`Debug code detected in diff:\n\`\`\`\n${debugPatterns}\n\`\`\``);
  }
} catch {
  // No debug patterns found — grep exits 1 when no matches
}

if (report.length === 1 && report[0] === 'TypeScript: no errors') {
  console.log('Post-turn review: clean (no TS errors, no missing tests, no debug code)');
} else {
  console.log(report.join('\n\n'));
}

process.exit(0);
Enter fullscreen mode Exit fullscreen mode

What Claude Does With Hook Output

Hook output goes into Claude's context as tool result content. Claude reads it on its next turn and can act on it. In practice:

  • ESLint errors from PostToolUse → Claude fixes them in a follow-up edit without prompting
  • Blocked write from PreToolUse → Claude rewrites the file using env variable references
  • TypeScript errors from Stop → Claude sees them at the top of its next turn and addresses them before responding to your next message

This feedback loop is what makes hooks powerful. You're not just logging to your terminal — you're injecting structured feedback into Claude's reasoning context.

Debugging Hooks

If a hook isn't firing or is behaving unexpectedly, check these in order:

# 1. Verify the hook file exists and is executable
ls -la ~/.claude/hooks/
chmod +x ~/.claude/hooks/*.js

# 2. Test the hook manually — pipe fake stdin
echo '{"session_id":"test","tool_name":"Edit","tool_input":{"file_path":"/tmp/test.ts","new_string":"console.log(\"test\")"}}' | node ~/.claude/hooks/lint-on-edit.js

# 3. Check Claude Code's settings actually loaded
# In Claude Code: /config — shows active settings including hooks

# 4. Check the hook is matched correctly — tool names are exact and case-sensitive
# 'edit' won't match — must be 'Edit'
Enter fullscreen mode Exit fullscreen mode

The most common failure mode is path issues. Node scripts need node explicitly in the command if the file doesn't have a shebang. Shell scripts need #!/bin/bash and chmod +x. When in doubt, wrap everything in node yourscript.js and use the full absolute path.

What This Buys You

With these three hooks active across all your Claude Code sessions:

  • Every TypeScript/JavaScript file edit triggers ESLint while Claude has full context to fix it
  • Every file write is screened for credentials before touching disk
  • Every session turn ends with a type-check and missing-test report
  • Debug code (console.log, debugger, TODO:) in diffs gets flagged automatically

The enforcement is at the execution layer, not the prompt layer. It doesn't matter if you forget to ask for lint cleanup or type checking — the hooks run every time regardless of what you typed. That's the point.


Skip the Boilerplate

The Ship Fast Skill Pack includes a complete Claude Code hooks bundle — secret scanning, lint-on-edit, type-check gate, debug-code detector, and a pre-commit sweep — all pre-configured and ready to drop into any TypeScript project.

$49 → whoffagents.com


Top comments (0)