18 KiB
/create-hook
Purpose
Create and configure Claude Code hooks with reference documentation and interactive guidance.
Contract
Inputs:
$1— HOOK_TYPE (optional: PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, SessionStart, SessionEnd)$2— MATCHER (optional: tool name pattern, e.g., "Bash", "Edit|Write", "*")$3— COMMAND (optional: shell command to execute)
Outputs: STATUS=<OK|FAIL> HOOK_FILE=<path>
Instructions
-
Detect common use cases (hybrid approach):
- Scan user's request for keywords: "eslint", "prettier", "format", "lint", "typescript", "test", "commit"
- If detected, ask: "I can set up a production-ready [TOOL] hook. Would you like to use the template or create a custom hook?"
- If template chosen: Generate external script file in
.claude/hooks/+ settings.json entry - If custom or no match: Fall back to current workflow (steps 2-3)
-
Determine hook configuration mode:
- If no arguments provided: Show interactive menu of hook types with examples
- If HOOK_TYPE provided: Guide user through creating that specific hook
- If all arguments provided: Create hook directly
-
Validate inputs:
- HOOK_TYPE must be one of the valid hook events
- MATCHER should be a valid tool name or pattern
- COMMAND should be a valid shell command or path to external script
- For external scripts: Ensure
.claude/hooks/directory exists
-
Reference documentation: Review the Claude Code hooks documentation for best practices and examples:
Hook Events:
PreToolUse: Runs before tool calls (can block them)PostToolUse: Runs after tool calls completeUserPromptSubmit: Runs when user submits a promptNotification: Runs when Claude Code sends notificationsStop: Runs when Claude Code finishes respondingSubagentStop: Runs when subagent tasks completePreCompact: Runs before compact operationsSessionStart: Runs when session starts/resumesSessionEnd: Runs when session ends
-
Production-Ready Script Templates:
When user requests common integrations, generate these external scripts in
.claude/hooks/:ESLint Auto-Fix (
run-eslint.sh):#!/usr/bin/env bash # Auto-fix JavaScript/TypeScript files with ESLint after edits set -euo pipefail # Extract file path from Claude's JSON payload file_path="$(jq -r '.tool_input.file_path // ""')" # Only process JS/TS files [[ "$file_path" =~ \.(js|jsx|ts|tsx)$ ]] || exit 0 # Auto-detect package manager (prefer project's lock file) if command -v pnpm >/dev/null 2>&1 && [ -f "pnpm-lock.yaml" ]; then PM="pnpm exec" elif command -v yarn >/dev/null 2>&1 && [ -f "yarn.lock" ]; then PM="yarn" else PM="npx" fi # Run ESLint with auto-fix from project root cd "$CLAUDE_PROJECT_DIR" && $PM eslint --fix "$file_path"Prettier Format (
run-prettier.sh):#!/usr/bin/env bash # Format files with Prettier after edits set -euo pipefail file_path="$(jq -r '.tool_input.file_path // ""')" # Skip non-formattable files [[ "$file_path" =~ \.(js|jsx|ts|tsx|json|css|scss|md|html|yml|yaml)$ ]] || exit 0 # Auto-detect package manager if command -v pnpm >/dev/null 2>&1 && [ -f "pnpm-lock.yaml" ]; then PM="pnpm exec" elif command -v yarn >/dev/null 2>&1 && [ -f "yarn.lock" ]; then PM="yarn" else PM="npx" fi cd "$CLAUDE_PROJECT_DIR" && $PM prettier --write "$file_path"TypeScript Type Check (
run-typescript.sh):#!/usr/bin/env bash # Run TypeScript type checker on TS/TSX file edits set -euo pipefail file_path="$(jq -r '.tool_input.file_path // ""')" # Only process TypeScript files [[ "$file_path" =~ \.(ts|tsx)$ ]] || exit 0 # Auto-detect package manager if command -v pnpm >/dev/null 2>&1 && [ -f "pnpm-lock.yaml" ]; then PM="pnpm exec" elif command -v yarn >/dev/null 2>&1 && [ -f "yarn.lock" ]; then PM="yarn" else PM="npx" fi # Run tsc --noEmit to check types without emitting files cd "$CLAUDE_PROJECT_DIR" && $PM tsc --noEmit --prettyRun Affected Tests (
run-tests.sh):#!/usr/bin/env bash # Run tests for modified files set -euo pipefail file_path="$(jq -r '.tool_input.file_path // ""')" # Only run tests for source files (not test files themselves) [[ "$file_path" =~ \.(test|spec)\.(js|ts|jsx|tsx)$ ]] && exit 0 [[ "$file_path" =~ \.(js|jsx|ts|tsx)$ ]] || exit 0 # Auto-detect test runner and package manager if [ -f "vitest.config.ts" ] || [ -f "vitest.config.js" ]; then TEST_CMD="vitest related --run" elif [ -f "jest.config.js" ] || [ -f "jest.config.ts" ]; then TEST_CMD="jest --findRelatedTests" else exit 0 # No test runner configured fi if command -v pnpm >/dev/null 2>&1 && [ -f "pnpm-lock.yaml" ]; then PM="pnpm exec" elif command -v yarn >/dev/null 2>&1 && [ -f "yarn.lock" ]; then PM="yarn" else PM="npx" fi cd "$CLAUDE_PROJECT_DIR" && $PM $TEST_CMD "$file_path"Commit Message Validation (
validate-commit.sh):#!/usr/bin/env bash # Validate commit messages follow conventional commits format set -euo pipefail # Extract commit message from tool input commit_msg="$(jq -r '.tool_input // ""')" # Check for conventional commit format: type(scope): message if ! echo "$commit_msg" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?: .+'; then echo "ERROR: Commit message must follow conventional commits format" echo "Expected: type(scope): description" echo "Got: $commit_msg" exit 2 # Block the commit fiCorresponding settings.json entries:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "comment": "Auto-fix with ESLint", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-eslint.sh" } ] } ] } } -
Best Practices for Hook Development:
Always use shell safety headers:
#!/usr/bin/env bash set -euo pipefail # Exit on error, undefined vars, pipe failuresExtract data from Claude's JSON payload:
# File path (Edit/Write tools) file_path="$(jq -r '.tool_input.file_path // ""')" # Command (Bash tool) command="$(jq -r '.tool_input.command // ""')" # Tool name tool_name="$(jq -r '.tool_name // ""')" # Full tool input tool_input="$(jq -r '.tool_input' | jq -c .)"Use environment variables provided by Claude:
$CLAUDE_PROJECT_DIR # Project root directory $CLAUDE_USER_DIR # User's ~/.claude directory $CLAUDE_SESSION_ID # Current session identifierEfficient file extension filtering:
# Good: Use bash regex matching [[ "$file_path" =~ \.(ts|tsx)$ ]] || exit 0 # Avoid: Spawning grep subprocess echo "$file_path" | grep -q '\.ts$' || exit 0Package manager auto-detection pattern:
# Check lock files to match project's package manager if command -v pnpm >/dev/null 2>&1 && [ -f "pnpm-lock.yaml" ]; then PM="pnpm exec" elif command -v yarn >/dev/null 2>&1 && [ -f "yarn.lock" ]; then PM="yarn" else PM="npx" # Fallback to npx fiExit codes matter:
exit 0 # Success: Allow operation to continue exit 1 # Error: Log error but don't block exit 2 # Block: Prevent operation in PreToolUse hooksPerformance considerations:
- Avoid heavy operations in tight loops (e.g., don't run full test suite on every file edit)
- Use file extension checks to skip irrelevant files early
- Consider async/background execution for slow operations
- Cache results when possible (e.g., dependency checks)
When to use external scripts vs inline commands:
- External scripts (
.claude/hooks/*.sh): Complex logic, multiple steps, reusable patterns - Inline commands: Simple one-liners, quick jq filters, logging
Example inline command:
"command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt" -
Common use cases and examples (updated with best practices):
Logging Bash commands:
{ "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "comment": "Log all Bash commands with descriptions", "command": "set -euo pipefail; cmd=$(jq -r '.tool_input.command // \"\"'); desc=$(jq -r '.tool_input.description // \"No description\"'); echo \"[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $cmd - $desc\" >> \"$CLAUDE_USER_DIR/bash-command-log.txt\"" }] }] } }Auto-format TypeScript files with Prettier:
{ "hooks": { "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "comment": "Auto-format TypeScript files after edits", "command": "set -euo pipefail; file_path=$(jq -r '.tool_input.file_path // \"\"'); [[ \"$file_path\" =~ \\.(ts|tsx)$ ]] || exit 0; cd \"$CLAUDE_PROJECT_DIR\" && npx prettier --write \"$file_path\"" }] }] } }Block sensitive file edits:
{ "hooks": { "PreToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "comment": "Prevent edits to sensitive files", "command": "set -euo pipefail; file_path=$(jq -r '.tool_input.file_path // \"\"'); if [[ \"$file_path\" =~ \\.(env|secrets|credentials) ]] || [[ \"$file_path\" == *\"package-lock.json\" ]] || [[ \"$file_path\" == *.git/* ]]; then echo \"ERROR: Cannot edit sensitive file: $file_path\" >&2; exit 2; fi" }] }] } }Desktop notifications:
{ "hooks": { "Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "comment": "Send desktop notifications", "command": "if command -v notify-send >/dev/null 2>&1; then notify-send 'Claude Code' 'Awaiting your input'; elif command -v osascript >/dev/null 2>&1; then osascript -e 'display notification \"Awaiting your input\" with title \"Claude Code\"'; fi" }] }] } } -
Generic Patterns Reference:
Pattern: Extract file path and check multiple extensions
set -euo pipefail file_path=$(jq -r '.tool_input.file_path // ""') [[ "$file_path" =~ \.(js|ts|jsx|tsx|py|go|rs)$ ]] || exit 0 # Your command herePattern: Process multiple files from array
set -euo pipefail jq -r '.tool_input.files[]?' | while IFS= read -r file; do [[ -f "$file" ]] && echo "Processing: $file" # Your processing logic here donePattern: Conditional execution based on directory
set -euo pipefail file_path=$(jq -r '.tool_input.file_path // ""') # Only process files in src/ directory [[ "$file_path" =~ ^src/ ]] || exit 0 # Your command herePattern: Extract and validate Bash command
set -euo pipefail cmd=$(jq -r '.tool_input.command // ""') # Block dangerous commands if [[ "$cmd" =~ (rm -rf|mkfs|dd|:(){:|:&};:) ]]; then echo "ERROR: Dangerous command blocked" >&2 exit 2 fiPattern: Background/async execution
set -euo pipefail file_path=$(jq -r '.tool_input.file_path // ""') # Run slow operation in background, don't block Claude (cd "$CLAUDE_PROJECT_DIR" && npm run build "$file_path" &> /tmp/build.log) &Pattern: Conditional tool execution
set -euo pipefail tool_name=$(jq -r '.tool_name // ""') case "$tool_name" in Edit|Write) file_path=$(jq -r '.tool_input.file_path // ""') echo "File modified: $file_path" ;; Bash) cmd=$(jq -r '.tool_input.command // ""') echo "Command executed: $cmd" ;; esacPattern: Multi-tool chain with error handling
set -euo pipefail file_path=$(jq -r '.tool_input.file_path // ""') [[ "$file_path" =~ \.(ts|tsx)$ ]] || exit 0 cd "$CLAUDE_PROJECT_DIR" # Run linter if ! npx eslint --fix "$file_path" 2>/dev/null; then echo "Warning: ESLint failed" >&2 fi # Run formatter (always runs even if linter fails) npx prettier --write "$file_path" 2>/dev/null || truePattern: Cache validation results
set -euo pipefail file_path=$(jq -r '.tool_input.file_path // ""') cache_file="/tmp/claude-hook-cache-$(echo \"$file_path\" | md5sum | cut -d' ' -f1)" # Check cache freshness if [[ -f "$cache_file" ]] && [[ "$cache_file" -nt "$file_path" ]]; then cat "$cache_file" exit 0 fi # Run expensive validation result=$(npx tsc --noEmit "$file_path" 2>&1) echo "$result" > "$cache_file" echo "$result"Pattern: Cross-platform compatibility
set -euo pipefail # Detect OS and use appropriate commands case "$(uname -s)" in Darwin*) # macOS osascript -e 'display notification "Build complete"' ;; Linux*) # Linux notify-send "Build complete" ;; MINGW*|MSYS*|CYGWIN*) # Windows powershell -Command "New-BurntToastNotification -Text 'Build complete'" ;; esac -
Security considerations:
- Hooks run automatically with your environment's credentials
- Always review hook implementation before registering
- Be cautious with hooks that execute external commands
- Avoid hardcoding sensitive data in hook commands
- Use exit code 2 to block operations in PreToolUse hooks
-
Hook creation workflow:
For common tools (ESLint, Prettier, TypeScript, Tests):
- Detect tool name from user request keywords
- Ask user: "I can set up a production-ready [TOOL] hook. Use template or create custom?"
- If template:
- Create
.claude/hooks/directory if needed - Generate appropriate script file (e.g.,
run-eslint.sh) - Make script executable (
chmod +x) - Add settings.json entry with
$CLAUDE_PROJECT_DIRreference - Verify package manager and dependencies exist
- Create
- Provide setup summary and next steps
For custom hooks:
-
Determine appropriate hook event for the use case
-
Define matcher pattern (specific tool name, regex like "Edit|Write", or "*" for all)
-
Write shell command that processes JSON input via stdin
-
Always start with
set -euo pipefailfor safety -
Use
$CLAUDE_PROJECT_DIRand other environment variables -
Test hook command independently before registering
-
Choose storage location:
.claude/settings.json- Project-specific, committed to git.claude/settings.local.json- Project-specific, not committed~/.claude/settings.json- User-wide, all projects
-
Register hook and reload with
/hookscommand -
Debugging hooks:
- Run
claude --debugto see hook execution logs - Use
/hookscommand to verify hook registration - Check hook configuration in settings files
- Test hook command standalone with sample JSON:
echo '{"tool_name":"Edit","tool_input":{"file_path":"test.ts"}}' | .claude/hooks/run-eslint.sh - Review Claude Code output for hook execution errors
- Use
jqto inspect JSON data passed to hooks - Check exit codes (0=success/continue, 1=error/log, 2=block operation)
- Verify file permissions (
chmod +xfor external scripts) - Check that
$CLAUDE_PROJECT_DIRresolves correctly
- Output:
Template mode:
- Create external script file in
.claude/hooks/[script-name].sh - Generate settings.json configuration snippet
- Make script executable
- Print:
STATUS=OK HOOK_FILE=.claude/hooks/[script-name].sh SETTINGS=.claude/settings.json - Show: Next steps (reload hooks, test the hook)
Interactive/Direct mode:
- Guide user through hook creation with prompts
- Show the complete JSON configuration to add
- Provide instructions for registering the hook
- Print:
STATUS=OK HOOK_FILE=~/.claude/settings.jsonorSTATUS=OK HOOK_FILE=.claude/settings.local.json - Remind user to run
/hooksto reload configuration
Constraints
- External scripts must be executable (
chmod +x) - Hooks must be valid shell commands or script paths
- JSON structure must follow hooks schema
- PreToolUse hooks can block operations with exit code 2
- Hooks should be idempotent and handle errors gracefully
- Consider performance impact of hooks in tight loops
- Always use
set -euo pipefailin bash scripts for safety - Use
$CLAUDE_PROJECT_DIRfor project-relative paths - Test hooks standalone before deploying
Examples
Template mode (recommended for common tools):
# User: "Set up ESLint to run on file edits"
/create-hook
# Detects "ESLint", offers template
# → Creates .claude/hooks/run-eslint.sh
# → Adds PostToolUse hook to settings.json
# → Makes script executable
# → STATUS=OK HOOK_FILE=.claude/hooks/run-eslint.sh
Interactive mode:
/create-hook
# Shows menu of:
# 1. Common tools (ESLint, Prettier, TypeScript, Tests)
# 2. Hook types (PreToolUse, PostToolUse, etc.)
# 3. Custom hook creation
Guided mode:
/create-hook PostToolUse
# Guides through creating a PostToolUse hook
# Asks for: matcher, command/script, storage location
Direct mode:
/create-hook PreToolUse "Bash" "set -euo pipefail; cmd=\$(jq -r '.tool_input.command'); echo \"[\$(date -Iseconds)] \$cmd\" >> \"\$CLAUDE_USER_DIR/bash.log\""
# Creates hook configuration directly with best practices
Reference
For complete documentation, see: https://docs.claude.com/en/hooks