Claude Code Hooks Troubleshooting

Tags: troubleshooting debugging error-handling diagnostics

A comprehensive guide to debugging and resolving common hook issues.

Quick Diagnostics

1. Check If Hooks Are Loading

# In Claude Code
/hooks
 
# Should show your configured hooks
# If empty, check settings.json location

2. Verify Configuration File

# Check file exists
ls -la .claude/settings.json
 
# Validate JSON syntax
jq . .claude/settings.json
 
# Check for syntax errors
python -m json.tool .claude/settings.json

3. Test Basic Hook

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Hook test successful' >> /tmp/claude-hook-test.txt"
          }
        ]
      }
    ]
  }
}

Common Issues and Solutions

Issue: Hook Not Triggering

Symptoms:

  • No output from hooks
  • Expected behavior doesn’t occur
  • No error messages

Solutions:

  1. Check Matcher Pattern:
# Test your regex
echo "Edit" | grep -E "Edit|Write"
 
# Common matcher mistakes
"Edit|Write"     # Correct
"Edit\|Write"    # Incorrect (unless you want literal |)
"Edit OR Write"  # Incorrect
  1. Verify Event Name:
{
  "hooks": {
    "PreToolUse": [],    // Correct
    "pretooluse": [],    // Incorrect (case sensitive)
    "PreTool": []        // Incorrect (wrong name)
  }
}
  1. Check File Location:
# Claude checks these locations in order:
1. ./.claude/settings.json          # Project-specific
2. ~/.claude/settings.json          # User-global
3. ./.claude/settings.local.json    # Local overrides

Issue: JSON Parsing Errors

Symptoms:

  • “Invalid JSON” errors
  • Hooks fail silently
  • Unexpected behavior

Solutions:

  1. Validate JSON Structure:
# Common JSON errors
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "echo test"  # Missing comma here
          }
          {  # This object will cause error
            "type": "command",
            "command": "echo test2"
          }
        ]
      }
    ]
  }
}
  1. Safe JSON Parsing in Hooks:
#!/bin/bash
# Read stdin safely
JSON_INPUT=$(cat)
 
# Validate before parsing
if ! echo "$JSON_INPUT" | jq empty 2>/dev/null; then
    echo "Error: Invalid JSON received" >&2
    exit 1
fi
 
# Now safe to use
TOOL_NAME=$(echo "$JSON_INPUT" | jq -r '.tool_name // "unknown"')

Issue: Exit Code Problems

Symptoms:

  • Operations not blocked when they should be
  • Hooks blocking unintentionally
  • Confusing behavior

Solutions:

  1. Understanding Exit Codes:
#!/bin/bash
# Exit code reference
 
# Success - continue normally
exit 0
 
# Block operation (PreToolUse only)
exit 2
 
# Warning - log but continue
exit 1
exit 3
exit 255
# Any non-zero except 2
  1. Testing Exit Codes:
#!/bin/bash
# Test different scenarios
 
case "$1" in
    "allow")
        echo "Allowing operation"
        exit 0
        ;;
    "block")
        echo "Blocking operation" >&2
        exit 2
        ;;
    "warn")
        echo "Warning but continuing" >&2
        exit 1
        ;;
esac

Issue: Performance Problems

Symptoms:

  • Claude Code becomes slow
  • Hooks timeout
  • Operations hang

Solutions:

  1. Add Timeouts:
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "timeout 5s my-slow-command || true",
            "timeout": 10000  // 10 second timeout
          }
        ]
      }
    ]
  }
}
  1. Profile Hook Performance:
#!/bin/bash
# Performance profiling wrapper
 
SCRIPT_TO_PROFILE="$1"
START=$(date +%s.%N)
 
# Run the actual hook
$SCRIPT_TO_PROFILE
 
RESULT=$?
END=$(date +%s.%N)
DURATION=$(echo "$END - $START" | bc)
 
# Log if slow
if (( $(echo "$DURATION > 1.0" | bc -l) )); then
    echo "[SLOW HOOK] $SCRIPT_TO_PROFILE took ${DURATION}s" >> ~/.claude/performance.log
fi
 
exit $RESULT
  1. Use Background Processing:
#!/bin/bash
# For non-critical long operations
 
# Start in background
{
    sleep 10
    curl -X POST https://api.example.com/notify
} &
 
# Return immediately
exit 0

Issue: Environment Problems

Symptoms:

  • Commands not found
  • Different behavior than manual testing
  • Path issues

Solutions:

  1. Debug Environment:
#!/bin/bash
# Save as debug_env.sh
 
{
    echo "=== Environment Debug ==="
    echo "PATH: $PATH"
    echo "PWD: $PWD"
    echo "USER: $USER"
    echo "SHELL: $SHELL"
    echo ""
    echo "=== Claude Variables ==="
    env | grep CLAUDE
    echo ""
    echo "=== Available Commands ==="
    for cmd in jq git npm python3 node curl; do
        if command -v $cmd &> /dev/null; then
            echo "✓ $cmd: $(command -v $cmd)"
        else
            echo "✗ $cmd: NOT FOUND"
        fi
    done
} >> ~/.claude/env-debug.log
  1. Fix PATH Issues:
#!/bin/bash
# Add common paths
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
 
# For macOS with Homebrew
export PATH="/opt/homebrew/bin:$PATH"
 
# For custom tools
export PATH="$HOME/.local/bin:$PATH"

Issue: File Access Problems

Symptoms:

  • Permission denied errors
  • File not found errors
  • Unexpected file paths

Solutions:

  1. Handle Spaces in Paths:
# WRONG
cat $CLAUDE_FILE_PATHS
 
# CORRECT
for file in $CLAUDE_FILE_PATHS; do
    cat "$file"  # Quote the variable
done
 
# BETTER
IFS=' ' read -ra FILES <<< "$CLAUDE_FILE_PATHS"
for file in "${FILES[@]}"; do
    cat "$file"
done
  1. Check File Existence:
#!/bin/bash
for file in $CLAUDE_FILE_PATHS; do
    if [ ! -f "$file" ]; then
        echo "Error: File not found: $file" >&2
        continue
    fi
    
    if [ ! -r "$file" ]; then
        echo "Error: Cannot read file: $file" >&2
        continue
    fi
    
    # Process file
    process_file "$file"
done

Advanced Debugging Techniques

1. Comprehensive Logging

#!/usr/bin/env python3
"""
Advanced debugging hook
Save as: .claude/hooks/debug_hook.py
"""
 
import json
import sys
import os
import traceback
import logging
from datetime import datetime
from pathlib import Path
 
# Setup detailed logging
log_dir = Path.home() / '.claude' / 'logs'
log_dir.mkdir(exist_ok=True)
 
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler(log_dir / 'hooks_debug.log'),
        logging.StreamHandler(sys.stderr)
    ]
)
 
logger = logging.getLogger('HookDebug')
 
def main():
    logger.info("="*50)
    logger.info("Hook execution started")
    
    try:
        # Log environment
        logger.debug("Environment variables:")
        for key, value in os.environ.items():
            if key.startswith('CLAUDE'):
                logger.debug(f"  {key}={value}")
        
        # Read and log input
        raw_input = sys.stdin.read()
        logger.debug(f"Raw input: {raw_input}")
        
        # Parse JSON
        data = json.loads(raw_input)
        logger.info(f"Parsed data: {json.dumps(data, indent=2)}")
        
        # Your hook logic here
        result = process_hook(data)
        
        logger.info("Hook completed successfully")
        return result
        
    except json.JSONDecodeError as e:
        logger.error(f"JSON parsing failed: {e}")
        logger.error(f"Input was: {raw_input[:200]}...")
        return 1
        
    except Exception as e:
        logger.error(f"Unexpected error: {e}")
        logger.error(traceback.format_exc())
        return 1
    
    finally:
        logger.info("Hook execution ended")
        logger.info("="*50)
 
def process_hook(data):
    # Your actual hook logic
    return 0
 
if __name__ == '__main__':
    sys.exit(main())

2. Interactive Debugging

#!/bin/bash
# Interactive debug mode
# Save as: .claude/hooks/interactive_debug.sh
 
if [ "$CLAUDE_DEBUG" = "true" ]; then
    echo "🔍 DEBUG MODE ACTIVATED" >&2
    echo "Press Ctrl+C to abort, Enter to continue" >&2
    read -r
    
    # Show current state
    echo "Current State:" >&2
    echo "  Tool: $CLAUDE_TOOL_NAME" >&2
    echo "  Files: $CLAUDE_FILE_PATHS" >&2
    echo "  PWD: $(pwd)" >&2
    
    # Start interactive shell for debugging
    echo "Starting debug shell. Type 'exit' to continue hook." >&2
    bash -i
fi
 
# Normal hook execution continues here

3. Hook Testing Framework

#!/usr/bin/env python3
"""
Test your hooks before deployment
Save as: test_hooks.py
"""
 
import subprocess
import json
import tempfile
from pathlib import Path
 
def test_hook(hook_path, test_cases):
    """Test a hook with various inputs"""
    results = []
    
    for test in test_cases:
        print(f"\nTesting: {test['name']}")
        print(f"Input: {json.dumps(test['input'], indent=2)}")
        
        # Run hook
        proc = subprocess.run(
            ['bash', hook_path] if hook_path.endswith('.sh') else ['python3', hook_path],
            input=json.dumps(test['input']),
            capture_output=True,
            text=True
        )
        
        # Check result
        success = proc.returncode == test.get('expected_exit', 0)
        
        print(f"Exit code: {proc.returncode} (expected: {test.get('expected_exit', 0)})")
        print(f"Stdout: {proc.stdout}")
        print(f"Stderr: {proc.stderr}")
        print(f"Result: {'✅ PASS' if success else '❌ FAIL'}")
        
        results.append({
            'test': test['name'],
            'success': success,
            'exit_code': proc.returncode,
            'stdout': proc.stdout,
            'stderr': proc.stderr
        })
    
    return results
 
# Example usage
if __name__ == '__main__':
    test_cases = [
        {
            'name': 'Normal file edit',
            'input': {
                'hook_event_name': 'PreToolUse',
                'tool_name': 'Edit',
                'tool_input': {'file_path': '/tmp/test.txt'}
            },
            'expected_exit': 0
        },
        {
            'name': 'Blocked file edit',
            'input': {
                'hook_event_name': 'PreToolUse',
                'tool_name': 'Edit',
                'tool_input': {'file_path': '.env'}
            },
            'expected_exit': 2
        }
    ]
    
    results = test_hook('.claude/hooks/security_check.sh', test_cases)
    
    # Summary
    passed = sum(1 for r in results if r['success'])
    print(f"\n{'='*50}")
    print(f"Tests passed: {passed}/{len(results)}")

Debugging Checklist

Before Writing Hooks

  • Understand the hook event lifecycle
  • Know which events can block operations
  • Plan error handling strategy
  • Set up logging infrastructure

During Development

  • Start with echo/print statements
  • Test hooks outside Claude first
  • Use meaningful error messages
  • Add timing information
  • Handle edge cases

When Issues Occur

  • Check hook is in correct location
  • Validate JSON syntax
  • Verify matcher patterns
  • Test with simple echo hook
  • Check file permissions
  • Review environment variables
  • Look for timeout issues
  • Check exit codes

Production Monitoring

  • Set up log rotation
  • Monitor hook performance
  • Track error rates
  • Set up alerts for failures
  • Regular hook audits

Emergency Procedures

Disable All Hooks

# Temporarily rename settings
mv .claude/settings.json .claude/settings.json.disabled
 
# Or use empty configuration
echo '{"hooks": {}}' > .claude/settings.json

Debug Mode

# Enable debug mode for all hooks
export CLAUDE_DEBUG=true
claude-code

Recovery Steps

  1. Disable problematic hooks
  2. Review recent changes
  3. Check system resources
  4. Restart Claude Code
  5. Test with minimal configuration
  6. Gradually re-enable hooks

Getting Help

Useful Commands

# View Claude Code version
claude-code --version
 
# Check current configuration
cat .claude/settings.json | jq .
 
# Find all hook files
find . -name "*.sh" -o -name "*.py" | grep -E "(hook|claude)"
 
# Search hook logs
grep -r "ERROR\|FAIL" ~/.claude/logs/

Resources

  • GitHub Issues: Report bugs
  • Documentation: /help in Claude Code
  • Community Forums: Share solutions
  • This guide: Keep it handy!

Remember: Most hook issues are configuration or environment related. Start with simple tests and build up complexity gradually.

See Also