Test your Claude Code hooks offline, before they break a real session.
Claude Code hooks are powerful -- they intercept tool calls, inject context, block dangerous commands, and automate post-session cleanup. But there is no built-in way to test them.
You write a Python hook, wire it into settings.local.json, and pray it works. If it has a syntax error, a missing import, or an unexpected exit code, you find out mid-session when Claude stalls or your hook silently fails. There is no --dry-run. There is no test harness. Until now.
This tool sends mock JSON payloads to each hook's stdin -- exactly like Claude Code does at runtime -- and validates:
- Exit codes (0 = pass, 2 = block, anything else = crash)
- Crash-free execution (syntax errors, import failures, unhandled exceptions)
- JSON output validity (if your hook returns JSON)
- Execution time (flags hooks slower than 5 seconds)
- Event mapping (auto-discovers which lifecycle event each hook handles)
Copy test-hooks.py into your project, or clone this repo:
git clone https://github.com/VoxCore84/claude-code-hook-tester.git
cd claude-code-hook-tester
# Test hooks in the current project (auto-discovers .claude/hooks/)
python test-hooks.py
# Test hooks in a specific project
python test-hooks.py --project-dir /path/to/my-project
# Point directly at a hooks directory
python test-hooks.py --hooks-dir /path/to/.claude/hooksNo dependencies. Pure Python 3.10+. No pip install needed.
- Auto-discovers all
.pyfiles in.claude/hooks/ - Reads
settings.local.json(orsettings.json) to determine which lifecycle event each hook handles - Falls back to
hook-manifest.jsonor filename heuristics if no settings file is found - Sends the correct mock payload to each hook via stdin
- Reports pass/fail/warn with colorized terminal output
==================================================================
Hook Test Harness for Claude Code
==================================================================
[ OK ] compact-reinject.py SessionStart exit=0 42ms
[ OK ] cpp-build-reminder.py PostToolUse exit=0 38ms
[ OK ] edit-verifier.py PostToolUse exit=0 51ms
[ OK ] large-file-guard.py PostToolUse exit=0 35ms
[ OK ] notification-toast.py Notification exit=0 29ms
[ OK ] precompact-snapshot.py PreCompact exit=0 44ms
[ OK ] prompt-context-injector.py UserPromptSubmit exit=0 67ms
[ OK ] session-stats.py PostToolUse exit=0 33ms
[WARN] sql-safety.py PreToolUse exit=2 41ms
-> Hook BLOCKED with reason: SQL contains DROP TABLE
[ OK ] stop-verify.py Stop exit=0 39ms
[ OK ] subagent-complete.py SubagentStop exit=0 31ms
[ OK ] sync-on-git.py PostToolUse exit=0 48ms
Results: 11 passed, 0 failed, 1 warnings out of 12 hooks
The harness reads your settings.local.json to understand hook wiring. Given this config:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/sql-safety.py\""
}
]
}
]
}
}It automatically maps sql-safety.py to the PreToolUse event and sends the correct mock payload.
Override any event's test payload by creating a payloads.json:
{
"PreToolUse": {
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /important-data"
},
"session_id": "test-dangerous-commands"
}
}python test-hooks.py --payloads payloads.jsonThis lets you test specific scenarios -- like verifying your safety hook blocks rm -rf.
python test-hooks.py --json{
"version": "1.0.0",
"total": 12,
"passed": 11,
"failed": 0,
"warnings": 1,
"results": [
{
"script": "sql-safety.py",
"event": "PreToolUse",
"status": "warn",
"exit_code": 2,
"duration_ms": 41.3,
"blocked": true,
"errors": [],
"warnings": ["Hook BLOCKED with reason: SQL contains DROP TABLE"]
}
]
}Use in GitHub Actions:
- name: Test Claude Code hooks
run: |
python tools/test-hooks.py --json > hook-results.json
python -c "
import json, sys
r = json.load(open('hook-results.json'))
if r['failed'] > 0:
print(f'FAILED: {r[\"failed\"]} hooks broken')
sys.exit(1)
print(f'All {r[\"total\"]} hooks passed')
"python test-hooks.py --verboseShows stdout and stderr from each hook, useful for debugging why a hook produces unexpected output.
If you don't use settings.local.json, create a hook-manifest.json in your hooks directory:
{
"sql-safety.py": ["PreToolUse"],
"edit-verifier.py": ["PostToolUse"],
"my-custom-hook.py": ["Stop", "SessionEnd"]
}A hook can be tested against multiple events by listing them in the array.
python test-hooks.py [OPTIONS]
Options:
--hooks-dir PATH Path to hooks directory (default: .claude/hooks/)
--settings PATH Path to settings.local.json for hook wiring
--manifest PATH Path to hook-manifest.json for manual mapping
--payloads PATH Path to custom payloads JSON file
--project-dir PATH Project root (sets CLAUDE_PROJECT_DIR for hooks)
--timeout SECONDS Per-hook timeout (default: 10)
--json Output results as JSON for CI
--verbose, -v Show hook stdout/stderr
--version Show version
| Issue | How It Surfaces |
|---|---|
| Syntax errors | FAIL -- exit code 1, stderr shows traceback |
| Missing imports | FAIL -- exit code 1, ModuleNotFoundError in stderr |
| Unexpected exit code | FAIL -- any exit code other than 0 or 2 |
| Unintended blocks | WARN -- exit code 2 means the hook would reject the tool call |
| Timeouts | FAIL -- hook exceeded the timeout threshold |
| Slow execution | WARN -- hook took longer than 5 seconds |
| Invalid JSON output | Noted in verbose mode (not an error, since hooks can print plain text) |
| Missing event mapping | GUES -- no settings file found, event inferred from filename |
Each lifecycle event receives a specific JSON structure on stdin. The built-in defaults cover all 10 Claude Code events:
| Event | Key Fields |
|---|---|
PreToolUse |
tool_name, tool_input |
PostToolUse |
tool_name, tool_input, tool_response |
PostToolUseFailure |
tool_name, tool_input, error |
UserPromptSubmit |
prompt |
PreCompact |
trigger |
SessionStart |
source |
Stop |
stop_reason, transcript_suffix |
Notification |
message |
SubagentStop |
(minimal) |
SessionEnd |
reason |
All payloads include hook_event_name and session_id. See payloads.json for the complete default set.
When determining which event a hook handles, the harness uses this priority chain:
settings.local.json/settings.json-- authoritative, parsed from your real hook wiringhook-manifest.json-- manual override file you create- Filename heuristics -- keywords like
safetymap toPreToolUse,toastmaps toNotification, etc.
The harness tells you when it falls back to heuristics (shown as GUES in the status column).
- Python 3.10+
- No external dependencies
MIT License. See LICENSE.
Built by VoxCore84