🎯

claude-hook-writer

🎯Skill

from pr-pm/prpm

VibeIndex|
What it does

Provides expert guidance for creating secure, reliable, and high-performance Claude Code hooks by validating design, enforcing best practices, and preventing common development pitfalls.

πŸ“¦

Part of

pr-pm/prpm(32 items)

claude-hook-writer

Installation

Shell ScriptRun shell script
./my-script.sh
npm installInstall npm package
npm install -g prettier
ConfigurationMCP configuration (may be incomplete)
{ "hooks": [{ "type": "command", "command": "./slow-operation.sh", ...
πŸ“– Extracted from docs: pr-pm/prpm
1Installs
86
-
Last UpdatedJan 22, 2026

Skill Details

SKILL.md

Expert guidance for writing secure, reliable, and performant Claude Code hooks - validates design decisions, enforces best practices, and prevents common pitfalls

Overview

# Claude Hook Writer

Use this skill when creating or improving Claude Code hooks. This skill ensures hooks are secure, reliable, performant, and follow best practices.

When to Use This Skill

  • Designing a new Claude Code hook
  • Reviewing existing hook code
  • Debugging hook failures
  • Optimizing slow hooks
  • Securing hooks that handle sensitive data
  • Publishing hooks as PRPM packages

Core Principles

1. Security is Non-Negotiable

Hooks execute automatically with user permissions. They can read, modify, or delete any file the user can access.

ALWAYS validate and sanitize all input. Hooks receive JSON via stdinβ€”never trust it blindly.

2. Reliability Over Features

A hook that works 99% of the time is a broken hook. Edge cases (Unicode filenames, spaces in paths, missing tools) will happen.

Test with edge cases before deploying.

3. Performance Matters

Hooks block operations. A 5-second hook means Claude waits 5 seconds before continuing.

Keep hooks fast. Run heavy operations in background.

4. Fail Gracefully

Missing dependencies, malformed input, and disk errors will occur.

Handle errors explicitly. Log failures. Return meaningful exit codes.

Hook Design Checklist

Before writing code, answer these questions:

What Event Does This Hook Target?

  • PreToolUse - Before tool execution (modify input, validate, block)
  • PostToolUse - After tool completes (format, log, cleanup)
  • UserPromptSubmit - Before user input processes (validate, enhance)
  • SessionStart - When Claude Code starts (setup, env check)
  • SessionEnd - When Claude Code exits (cleanup, persist state)
  • Notification - During alerts (desktop notifications, logging)
  • Stop / SubagentStop - When responses finish (cleanup, summary)
  • PreCompact - Before context compaction (save important context)

Common mistake: Using PostToolUse for validation (too lateβ€”tool already ran). Use PreToolUse to block operations.

Which Tools Should Trigger This Hook?

Be specific. matcher: "*" runs on every tool call.

Good matchers:

  • "Write" - Only file writes
  • "Edit|Write" - File modifications
  • "Bash" - Shell commands
  • "mcp__github__*" - All GitHub MCP tools

Bad matchers:

  • "*" - Everything (use only for logging/metrics)

What Input Does This Hook Need?

Different tools provide different input. Check what's available:

```bash

# PreToolUse / PostToolUse

{

"input": {

"file_path": "/path/to/file.ts", // Read, Write, Edit

"command": "npm test", // Bash

"old_string": "...", // Edit

"new_string": "..." // Edit

}

}

```

Validate fields exist before using them:

```bash

FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')

if [[ -z "$FILE" ]]; then

echo "No file path provided" >&2

exit 1

fi

```

Should This Be a Command Hook or Prompt Hook?

Command hooks (type: "command"):

  • Fast (milliseconds)
  • Deterministic
  • Good for: formatting, logging, file checks

Prompt hooks (type: "prompt"):

  • Slow (2-10 seconds)
  • Context-aware (uses LLM)
  • Good for: complex validation, security analysis, intent detection

Rule of thumb: Use command hooks unless you need LLM reasoning.

What Exit Code Communicates Success/Failure?

  • exit 0 - Success (continue operation)
  • exit 2 - Block operation (show error to Claude)
  • exit 1 or other - Non-blocking error (log but continue)

For PreToolUse hooks:

  • Exit 2 blocks the tool from running
  • Exit 0 allows it (optionally with modified input)

For PostToolUse hooks:

  • Exit codes don't block (tool already ran)
  • Use exit 0 for success, 1 for logging errors

Security Requirements

MUST-HAVE Security Checks

Every hook must implement these:

#### 1. Input Validation

```bash

#!/bin/bash

set -euo pipefail # Exit on errors, undefined vars

INPUT=$(cat)

# Validate JSON parse

if ! FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty' 2>&1); then

echo "JSON parse failed: $FILE" >&2

exit 1

fi

# Validate field exists

if [[ -z "$FILE" ]]; then

echo "No file path in input" >&2

exit 1

fi

```

#### 2. Path Sanitization

```bash

# Validate file is in project

if [[ "$FILE" != "$CLAUDE_PROJECT_DIR"* ]]; then

echo "File outside project: $FILE" >&2

exit 2 # Block operation

fi

# Validate no directory traversal

if [[ "$FILE" == ".." ]]; then

echo "Path traversal detected: $FILE" >&2

exit 2

fi

```

#### 3. Sensitive File Protection

```bash

# Block list (extend as needed)

BLOCKED_PATTERNS=(

".env"

".env.*"

"*.pem"

"*.key"

"credentials"

".git/*"

".ssh/*"

)

for pattern in "${BLOCKED_PATTERNS[@]}"; do

if [[ "$FILE" == $pattern ]]; then

echo "Blocked: $FILE matches sensitive pattern $pattern" >&2

exit 2

fi

done

```

#### 4. Quote All Variables

Spaces and special characters in paths break unquoted variables:

```bash

# WRONG

cat $FILE # Breaks on "my file.txt"

prettier --write $FILE # Fails with spaces

# RIGHT

cat "$FILE" # Handles spaces

prettier --write "$FILE" # Safe

```

#### 5. Use Absolute Paths for Scripts

```bash

# WRONG - relative path might not resolve

./my-script.sh

# RIGHT - explicit path

"${CLAUDE_PLUGIN_ROOT}/scripts/my-script.sh"

# ALSO RIGHT - use full path

/Users/username/.claude/scripts/my-script.sh

```

Reliability Requirements

Handle Missing Dependencies

```bash

# Check tool exists

if ! command -v prettier &> /dev/null; then

echo "prettier not installed, skipping" >&2

exit 0 # Success exit (just skip)

fi

# Check file exists

if [[ ! -f "$FILE" ]]; then

echo "File not found: $FILE" >&2

exit 1

fi

```

Set Timeouts

Default is 60 seconds. For slow operations, set explicit timeout:

```json

{

"hooks": [{

"type": "command",

"command": "./slow-operation.sh",

"timeout": 10000 // 10 seconds

}]

}

```

Or run in background:

```bash

# Don't block Claude

(heavy_operation "$FILE" &)

exit 0

```

Log Errors Properly

```bash

LOG_FILE=~/.claude-hooks/my-hook.log

# Log to stderr (shown in transcript)

echo "Hook failed: some reason" >&2

# Or log to file (for debugging)

echo "[$(date)] Error: some reason" >> "$LOG_FILE"

```

Don't log to stdout unless you want output in Claude's transcript.

Test With Edge Cases

Test files:

  • "file with spaces.txt"
  • "ζ–‡δ»Ά.txt" (Unicode)
  • "src/deep/nested/path/file.tsx" (deep paths)
  • "/absolute/path.txt" (absolute paths)
  • "../../../etc/passwd" (traversal attempts)

Test input:

  • Malformed JSON
  • Missing fields
  • Empty strings
  • null values

Performance Requirements

Keep Hooks Fast

Target < 100ms for PreToolUse hooks. Longer hooks block Claude visibly.

Slow operations:

  • Running tests: Run in background or use PostToolUse
  • Type checking: Cache results by file hash
  • Network calls: Avoid in hooks (use subagents instead)
  • Heavy linting: Only lint changed file, not entire project

Use Specific Matchers

```json

// BAD - runs on everything

{"matcher": "*", ...}

// GOOD - only file writes

{"matcher": "Write", ...}

// BETTER - only TypeScript writes

// (check file extension in hook)

{"matcher": "Write", ...}

```

Dedupe Expensive Operations

If multiple hooks match, they run in parallel. Dedupe with locks:

```bash

LOCK_FILE="/tmp/claude-hook-${SESSION_ID}-${HOOK_NAME}.lock"

if [[ -f "$LOCK_FILE" ]]; then

exit 0 # Already running

fi

touch "$LOCK_FILE"

trap "rm -f '$LOCK_FILE'" EXIT # Clean up on exit

# Do work here

expensive_operation

```

Code Templates

Template: Format On Save Hook

```bash

#!/bin/bash

set -euo pipefail

# Parse input

INPUT=$(cat)

FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')

# Validate

[[ -n "$FILE" ]] || exit 0

[[ -f "$FILE" ]] || exit 0

[[ "$FILE" == "$CLAUDE_PROJECT_DIR"* ]] || exit 0

# Check formatter installed

if ! command -v prettier &> /dev/null; then

exit 0

fi

# Format by extension

case "$FILE" in

.ts|.tsx|.js|.jsx)

prettier --write "$FILE" 2>/dev/null || exit 0

;;

*.py)

black "$FILE" 2>/dev/null || exit 0

;;

*.go)

gofmt -w "$FILE" 2>/dev/null || exit 0

;;

esac

```

JSON config:

```json

{

"hooks": {

"PostToolUse": [{

"matcher": "Edit|Write",

"hooks": [{

"type": "command",

"command": "/path/to/format-on-save.sh",

"timeout": 5000

}]

}]

}

}

```

Template: Block Sensitive Files Hook

```bash

#!/bin/bash

set -euo pipefail

INPUT=$(cat)

FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')

[[ -n "$FILE" ]] || exit 0

# Sensitive patterns

BLOCKED=(

".env"

".env.*"

"*.pem"

"*.key"

"secret"

"credential"

".git/*"

)

for pattern in "${BLOCKED[@]}"; do

# Use case for glob matching

case "$FILE" in

$pattern)

echo "🚫 Blocked: $FILE is a sensitive file" >&2

echo " Pattern: $pattern" >&2

exit 2 # Block operation

;;

esac

done

exit 0 # Allow

```

JSON config:

```json

{

"hooks": {

"PreToolUse": [{

"matcher": "Edit|Write",

"hooks": [{

"type": "command",

"command": "/path/to/block-sensitive.sh"

}]

}]

}

}

```

Template: Command Logger Hook

```bash

#!/bin/bash

set -euo pipefail

INPUT=$(cat)

COMMAND=$(echo "$INPUT" | jq -r '.input.command // empty')

[[ -n "$COMMAND" ]] || exit 0

LOG_FILE=~/claude-commands.log

mkdir -p "$(dirname "$LOG_FILE")"

# Log with timestamp and context

{

echo "---"

echo "Time: $(date '+%Y-%m-%d %H:%M:%S')"

echo "Directory: $CLAUDE_CURRENT_DIR"

echo "Command: $COMMAND"

} >> "$LOG_FILE"

exit 0

```

JSON config:

```json

{

"hooks": {

"PreToolUse": [{

"matcher": "Bash",

"hooks": [{

"type": "command",

"command": "/path/to/command-logger.sh"

}]

}]

}

}

```

Template: Prompt-Based Security Hook

```json

{

"hooks": {

"PreToolUse": [{

"matcher": "Write",

"hooks": [{

"type": "prompt",

"prompt": "Analyze the file content being written to ${input.file_path}. Check if it contains: hardcoded API keys, AWS credentials, private keys, passwords, or secrets. Return {\"decision\": \"block\", \"reason\": \"\"} if found, otherwise {\"decision\": \"allow\"}.",

"schema": {

"type": "object",

"properties": {

"decision": {"enum": ["allow", "block"]},

"reason": {"type": "string"}

},

"required": ["decision"]

}

}]

}]

}

}

```

Use sparingly: Prompt hooks take 2-10 seconds. Only use for critical security checks.

Testing Hooks

Manual Testing

Create test input:

```bash

# Test with sample JSON

echo '{

"session_id": "test",

"input": {

"file_path": "/tmp/test.ts"

}

}' | ./my-hook.sh

# Check exit code

echo $? # 0 = success, 2 = blocked, 1 = error

```

Edge Case Testing

```bash

#!/bin/bash

# test-hook.sh

HOOK=./my-hook.sh

test_case() {

local description="$1"

local input="$2"

local expected_exit="$3"

echo "Testing: $description"

echo "$input" | $HOOK

actual_exit=$?

if [[ $actual_exit -eq $expected_exit ]]; then

echo " βœ“ PASS"

else

echo " βœ— FAIL (expected exit $expected_exit, got $actual_exit)"

return 1

fi

}

# Test cases

test_case "Normal file" \

'{"input":{"file_path":"/tmp/test.ts"}}' \

0

test_case "Sensitive .env file" \

'{"input":{"file_path":".env"}}' \

2

test_case "File with spaces" \

'{"input":{"file_path":"/tmp/my file.ts"}}' \

0

test_case "Missing file_path" \

'{"input":{}}' \

1

test_case "Malformed JSON" \

'not json' \

1

echo "All tests passed"

```

Integration Testing

  1. Register hook in Claude Code
  2. Trigger the event (write file, run command)
  3. Check transcript (Ctrl-R) for hook output
  4. Verify expected behavior

Publishing Hooks as PRPM Packages

Package Structure

```

my-hook/

β”œβ”€β”€ prpm.json # Package manifest

β”œβ”€β”€ hook.json # Hook configuration

β”œβ”€β”€ scripts/

β”‚ └── my-hook.sh # Hook script

└── README.md # Documentation

```

prpm.json

```json

{

"name": "@yourname/my-hook",

"version": "1.0.0",

"description": "Brief description of what hook does (shown in search)",

"author": "Your Name",

"format": "claude",

"subtype": "hook",

"tags": [

"formatting",

"security",

"automation"

],

"main": "hook.json",

"scripts": {

"test": "./test-hook.sh"

}

}

```

hook.json

```json

{

"hooks": {

"PostToolUse": [{

"matcher": "Edit|Write",

"hooks": [{

"type": "command",

"command": "${CLAUDE_PLUGIN_ROOT}/scripts/my-hook.sh",

"timeout": 5000

}]

}]

}

}

```

Use ${CLAUDE_PLUGIN_ROOT} to reference scriptsβ€”expands to hook installation directory.

Advanced Hook Configuration

All hook types support optional fields for controlling execution behavior:

```json

{

"hooks": {

"PreToolUse": [{

"matcher": "Write",

"hooks": [{

"type": "command",

"command": "./my-hook.sh",

"timeout": 5000,

"continue": true, // Whether Claude continues after hook (default: true)

"stopReason": "string", // Message shown when continue is false

"suppressOutput": false, // Hide stdout from transcript (default: false)

"systemMessage": "string" // Warning message shown to user

}]

}]

}

}

```

#### continue (boolean, default: true)

Controls whether Claude continues after hook execution.

When to use false:

  • Security hooks that must block operations
  • Validation hooks that found critical errors
  • Hooks that require user intervention

```json

{

"type": "command",

"command": "./validate-security.sh",

"continue": false,

"stopReason": "Security validation failed. Please review the detected issues before proceeding."

}

```

Exit code interaction:

  • If hook exits with code 2 (block): continue is ignored, operation is blocked
  • If hook exits with code 0 or 1: continue field determines behavior

#### stopReason (string)

Message displayed to user when continue: false. Should explain why execution stopped and what action is needed.

```json

{

"continue": false,

"stopReason": "Pre-commit checks failed. Fix linting errors and try again."

}

```

#### suppressOutput (boolean, default: false)

Hides hook stdout from transcript mode (Ctrl-R). Stderr is always shown.

When to use true:

  • Hooks that produce verbose output
  • Debugging logs not useful to users
  • Noisy background operations

```json

{

"type": "command",

"command": "./sync-to-cloud.sh",

"suppressOutput": true // Don't show sync progress in transcript

}

```

Note: Always show critical errors via stderr, as stderr is never suppressed.

#### systemMessage (string)

Warning or info message shown to user when hook executes. Useful for non-blocking warnings.

```json

{

"type": "command",

"command": "./check-dependencies.sh",

"systemMessage": "⚠️ Some dependencies are outdated. Consider running 'npm update'."

}

```

Difference from stopReason:

  • systemMessage: Informational, Claude continues
  • stopReason: Critical, requires continue: false

README.md

```markdown

# My Hook

Brief description.

What It Does

  • Clear, specific bullet points
  • Mention which events it triggers on
  • Mention which tools it matches

Installation

```bash

prpm install @yourname/my-hook

```

Requirements

  • prettier (install: npm install -g prettier)
  • jq (install: brew install jq)

Configuration

Optional: How to customize behavior.

Examples

Show example output or behavior.

Troubleshooting

Common issues and fixes.

```

Publishing

```bash

# Test locally first

prpm test

# Publish

prpm publish

# Version bumps

prpm publish patch # 1.0.0 -> 1.0.1

prpm publish minor # 1.0.0 -> 1.1.0

prpm publish major # 1.0.0 -> 2.0.0

```

Common Pitfalls

❌ Pitfall 1: Not Quoting Variables

```bash

# BREAKS on spaces

prettier --write $FILE

# SAFE

prettier --write "$FILE"

```

❌ Pitfall 2: Trusting Input

```bash

# DANGEROUS - no validation

FILE=$(jq -r '.input.file_path')

rm "$FILE"

# SAFE - validate first

FILE=$(jq -r '.input.file_path // empty')

[[ "$FILE" == "$CLAUDE_PROJECT_DIR"* ]] || exit 2

[[ "$FILE" != ".env" ]] || exit 2

rm "$FILE"

```

❌ Pitfall 3: Blocking Operations Too Long

```bash

# BLOCKS Claude for 30 seconds

npm test

# RUN IN BACKGROUND

(npm test &)

exit 0

```

❌ Pitfall 4: Wrong Exit Code

```bash

# PreToolUse hook that should block

if [[ $FILE == ".env" ]]; then

echo "Don't edit .env" >&2

exit 1 # WRONG - doesn't block, just logs error

fi

# RIGHT

if [[ $FILE == ".env" ]]; then

echo "Blocked: .env is protected" >&2

exit 2 # Blocks operation

fi

```

❌ Pitfall 5: Logging to stdout

```bash

# WRONG - appears in transcript

echo "Hook running..."

# RIGHT - stderr or file

echo "Hook running..." >&2

# or

echo "Hook running..." >> ~/.claude-hooks/debug.log

```

❌ Pitfall 6: Assuming Tools Exist

```bash

# BREAKS if prettier not installed

prettier --write "$FILE"

# SAFE

if command -v prettier &>/dev/null; then

prettier --write "$FILE"

fi

```

Debugging Hooks

Enable Verbose Logging

```bash

#!/bin/bash

set -x # Print commands as they execute

```

Check Transcript

Run Claude Code with Ctrl-R (transcript mode) to see hook execution:

```

PreToolUse hook: ./my-hook.sh

stdout: Formatted file.ts

stderr:

exit: 0

duration: 47ms

```

Test JSON Parsing

```bash

# Debug what jq extracts

INPUT=$(cat)

echo "$INPUT" | jq '.' >&2 # Show full JSON

echo "$INPUT" | jq -r '.input.file_path' >&2 # Show field

```

Check Environment Variables

```bash

echo "PROJECT_DIR: $CLAUDE_PROJECT_DIR" >&2

echo "CURRENT_DIR: $CLAUDE_CURRENT_DIR" >&2

echo "SESSION_ID: $SESSION_ID" >&2

echo "PLUGIN_ROOT: $CLAUDE_PLUGIN_ROOT" >&2

```

Quick Reference

Exit Codes

  • 0 = Success (continue)
  • 2 = Block operation (PreToolUse only)
  • 1 or other = Non-blocking error

Hook Configuration Fields

Required:

  • type - "command" or "prompt"
  • command or prompt - Script path or prompt text

Optional:

  • timeout - Max execution time in ms (default: 60000)
  • continue - Continue after hook? (default: true)
  • stopReason - Message when continue=false
  • suppressOutput - Hide stdout from transcript (default: false)
  • systemMessage - Warning message to user

Environment Variables

  • $CLAUDE_PROJECT_DIR - Project root
  • $CLAUDE_CURRENT_DIR - Current directory
  • $SESSION_ID - Session identifier
  • $CLAUDE_PLUGIN_ROOT - Hook installation directory
  • $CLAUDE_ENV_FILE - File for persisting vars

JSON Input Structure

```json

{

"session_id": "...",

"transcript_path": "...",

"current_dir": "...",

"input": {

// Tool-specific fields

}

}

```

Common jq Patterns

```bash

# Extract with default

$(jq -r '.input.file_path // empty')

# Extract array

$(jq -r '.input.files[]')

# Check field exists

if jq -e '.input.file_path' >/dev/null; then

# Parse entire object

INPUT_OBJ=$(jq '.input')

```

Final Checklist

Before publishing:

  • [ ] Validates all stdin input
  • [ ] Quotes all variables
  • [ ] Uses absolute paths for scripts
  • [ ] Blocks sensitive files
  • [ ] Handles missing tools gracefully
  • [ ] Sets reasonable timeout
  • [ ] Logs errors to stderr or file
  • [ ] Tests with edge cases
  • [ ] Tests in real Claude session
  • [ ] Documents dependencies
  • [ ] README includes examples
  • [ ] Semantic version number
  • [ ] Clear description and tags

Resources

  • [Claude Code Hooks Docs](https://code.claude.com/docs/en/hooks)
  • [Claude Hooks Best Practices Blog](/blog/claude-hooks-best-practices)
  • [PRPM Hook Packages](https://prpm.dev/packages?format=claude&subtype=hook)
  • [Hook Examples Repo](https://github.com/pr-pm/claude-hooks-examples)