From srnnkls-tropos
Test Claude Code hooks in isolation and via integration. Use when developing, debugging, or validating hook behavior.
npx claudepluginhub joshuarweaver/cascade-code-general-misc-2 --plugin srnnkls-troposThis skill uses the workspace's default tool permissions.
Test hooks at three levels: unit tests (Python), direct invocation, and headless Claude.
Creates isolated Git worktrees for feature branches with prioritized directory selection, gitignore safety checks, auto project setup for Node/Python/Rust/Go, and baseline verification.
Executes implementation plans in current session by dispatching fresh subagents per independent task, with two-stage reviews: spec compliance then code quality.
Dispatches parallel agents to independently tackle 2+ tasks like separate test failures or subsystems without shared state or dependencies.
Test hooks at three levels: unit tests (Python), direct invocation, and headless Claude.
Reference: See reference.md for complete payload schemas.
Test hook logic in isolation using the test harness with pytest.
Use templates/test-harness.py:
from test_harness import create_payload, run_hook, assert_blocked, assert_allowed
def test_blocks_dangerous_commands():
payload = create_payload("PreToolUse", tool_name="Bash",
tool_input={"command": "rm -rf /"})
result = run_hook("./my-hook.py", payload)
assert_blocked(result, "dangerous")
def test_allows_safe_commands():
payload = create_payload("PreToolUse", tool_name="Bash",
tool_input={"command": "echo hello"})
result = run_hook("./my-hook.py", payload)
assert_allowed(result)
def test_modifies_input():
payload = create_payload("PreToolUse", tool_name="Write",
tool_input={"file_path": "/tmp/test.txt"})
result = run_hook("./my-hook.py", payload)
assert_modified_input(result, "file_path", "/safe/path/test.txt")
Run with pytest:
pytest test_my_hook.py -v
| Function | Checks |
|---|---|
assert_blocked(result, msg) | Exit code 2, stderr contains msg |
assert_allowed(result) | Exit code 0, no block decision |
assert_modified_input(result, field, value) | Exit 0, updatedInput has field |
assert_context_added(result, contains) | Exit 0, context includes text |
Test hooks by calling them directly with JSON payloads.
echo '{"hook_event_name": "PreToolUse", "tool_name": "Bash", ...}' | ./hook.py
echo "Exit: $?"
cat > /tmp/payload.json << 'EOF'
{
"session_id": "test-123",
"transcript_path": "/tmp/test.jsonl",
"cwd": "/path/to/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /"},
"tool_use_id": "toolu_01ABC"
}
EOF
cat /tmp/payload.json | ./my-hook.py
| Exit Code | Meaning | Check |
|---|---|---|
| 0 | Success/allow | stdout contains valid JSON or context |
| 2 | Block action | stderr contains reason for Claude |
| Other | Non-blocking error | stderr contains user message |
Test hooks end-to-end by invoking Claude in headless mode. Claude executes normally, triggers hooks through its behavior, and you verify the outcome.
claude -p "prompt here" --debug
| Flag | Purpose |
|---|---|
-p / --print | Headless mode - prints response and exits |
--debug | Shows hook execution details in stderr |
--allowedTools | Limit which tools Claude can use |
--permission-mode | Control permission behavior |
Test PreToolUse blocks dangerous commands:
# Prompt that would trigger dangerous Bash command
output=$(claude -p "delete everything in /tmp" --debug 2>&1)
# Check hook blocked it (look for your hook's block message)
if echo "$output" | grep -q "blocked"; then
echo "PASS: Hook blocked dangerous command"
fi
Test PostToolUse reacts to failures:
# Prompt that triggers a command expected to fail
output=$(claude -p "run: exit 1" --debug 2>&1)
# Check hook's reaction appears in output
echo "$output" | grep -q "Hook command completed"
Test UserPromptSubmit adds context:
# Any prompt triggers UserPromptSubmit
output=$(claude -p "hello" --debug 2>&1)
# Verify hook ran
echo "$output" | grep "UserPromptSubmit"
Test Stop hook continues execution:
# Prompt that completes, triggering Stop hook
output=$(claude -p "what is 2+2" --debug 2>&1)
# Check if hook caused continuation
echo "$output" | grep "Stop"
[DEBUG] Executing hooks for PreToolUse:Bash
[DEBUG] Found 1 hook matchers in settings
[DEBUG] Matched 1 hooks for query "Bash"
[DEBUG] Executing hook command: ./my-hook.py with timeout 60000ms
[DEBUG] Hook command completed with status 0: <stdout content>
#!/bin/bash
# integration-test.sh - Run from project root with hook configured
set -e
echo "Test 1: PreToolUse blocks rm -rf"
if claude -p "run: rm -rf /" --debug 2>&1 | grep -qi "block"; then
echo " PASS"
else
echo " FAIL"
exit 1
fi
echo "Test 2: Safe commands allowed"
if claude -p "run: echo hello" --debug 2>&1 | grep -q "hello"; then
echo " PASS"
else
echo " FAIL"
exit 1
fi
echo "All tests passed"
Use .claude/settings.local.json for test hooks (not committed):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [".claude/hooks/test-blocker.py"]
}
]
}
}
payload = create_payload("PreToolUse",
tool_name="Write",
tool_input={"file_path": "/etc/passwd", "content": "..."})
Test: block dangerous paths, allow safe ops, modify input via updatedInput
payload = create_payload("PostToolUse",
tool_name="Bash",
tool_input={"command": "npm test"},
tool_response={"stdout": "FAILED", "exit_code": 1})
Test: react to failures, add context, log operations
payload = create_payload("UserPromptSubmit", prompt="delete all files")
Test: block prohibited prompts, add context, transform input
payload = create_payload("Stop", stop_hook_active=False)
Test: continue on conditions, verify TDD evidence, prevent loops when stop_hook_active=True
/hooks menu, verify matcher syntax"timeout": 120000sys.exit(2) to block, sys.exit(0) to allowctrl+o)Related:
claude --help for CLI flags/hooks to manage hooks.claude/settings.json, .claude/settings.local.json