hook-architecture
This skill should be used when the user asks about "how hooks work", "UserPromptSubmit", "hook-driven testing", "test wrapper", "prompt interception", "hook JSON format", "state management", or needs to understand the architecture of hook-driven automated testing.
SKILL.md
| Name | hook-architecture |
| Description | This skill should be used when the user asks about "how hooks work", "UserPromptSubmit", "hook-driven testing", "test wrapper", "prompt interception", "hook JSON format", "state management", or needs to understand the architecture of hook-driven automated testing. |
allowed-tools:
- Bash
- Glob
- Grep
- Read
- Write description: This skill should be used when the user asks about "how hooks work", "UserPromptSubmit", "hook-driven testing", "test wrapper", "prompt interception", "hook JSON format", "state management", or needs to understand the architecture of hook-driven automated testing. name: hook-architecture version: 1.0.0
<!-- BEGIN MNEMONIC PROTOCOL -->
Memory
Search first: /mnemonic:search {relevant_keywords}
Capture after: /mnemonic:capture {namespace} "{title}"
Run /mnemonic:list --namespaces to see available namespaces from loaded ontologies.
Hook-Driven Test Architecture
Understand the hook system that powers automated testing in Claude Code.
Mnemonic Integration
Before explaining hook architecture, check mnemonic for prior learnings:
# Search for hook-related memories
rg -i "hook|UserPromptSubmit|test-wrapper|prompt-interception" ~/.claude/mnemonic/ ./.claude/mnemonic/ --glob "*.memory.md" -l | head -5
Apply recalled context:
- Prior hook implementations that worked well
- Common pitfalls with JSON escaping or state management
- User's familiarity level with hook concepts
Architecture Overview
The hook-driven test framework transforms Claude Code's conversational interface into an automated test harness by intercepting user prompts and replacing them with test instructions.
┌─────────────────────────────────────────────────────┐
│ Claude Code Session │
├─────────────────────────────────────────────────────┤
│ User: "next" │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ UserPromptSubmit Hook │ │
│ │ (test-wrapper.sh) │ │
│ │ │ │
│ │ 1. Check test mode active │ │
│ │ 2. Intercept command │ │
│ │ 3. Call runner.sh │ │
│ │ 4. Return {"replace": "new prompt"} │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Claude sees: "Execute test: Call tool X..." │
│ │ │
│ ▼ │
│ Claude executes the MCP tool call │
└─────────────────────────────────────────────────────┘
Core Components
1. UserPromptSubmit Hook
The hook intercepts every user prompt and can:
- Pass through: Return unchanged for normal operation
- Replace: Substitute with test instruction
- Block: Prevent prompt from reaching Claude
Hook registration in hooks/hooks.json:
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/test-wrapper.sh user-prompt-submit"
}
]
}
]
}
}
2. Test Wrapper Script
The wrapper (hooks/test-wrapper.sh) handles prompt interception:
#!/usr/bin/env bash
handle_user_prompt_submit() {
local input=$(cat)
local prompt=$(echo "$input" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('prompt', ''))
")
# Check if test mode active
if is_test_mode; then
case "$prompt" in
next|n)
output=$("$RUNNER" next)
json_replace "Execute the following test:\n\n$output"
;;
validate*)
response="${prompt#validate }"
output=$("$RUNNER" validate "$response")
json_replace "$output"
;;
# ... other commands
esac
else
# Pass through to normal processing
echo "$input"
fi
}
3. Runner Script
The runner (tests/functional/runner.sh) orchestrates test execution:
Key functions:
cmd_init- Initialize test statecmd_next- Return next test actioncmd_validate- Check response against expectationscmd_status- Report progresscmd_report- Generate results
4. State Management
Persistent state in .claude/test-state.json:
{
"mode": "running",
"total_tests": 53,
"current_index": 5,
"current_test": {
"id": "test_id",
"action": "...",
"expect": [...]
},
"results": [
{"id": "test1", "status": "pass"},
{"id": "test2", "status": "fail", "failures": ["..."]}
],
"saved_vars": {
"memory_id": "abc123"
}
}
Hook Input/Output Format
Input (JSON via stdin)
{
"session_id": "abc123",
"prompt": "user's typed command",
"cwd": "/project/path"
}
Output (JSON to stdout)
Replace prompt:
{
"replace": "New prompt text for Claude"
}
Pass through:
{}
Block prompt:
{
"continue": false,
"systemMessage": "Explanation"
}
Critical Implementation Details
JSON Escaping
Hook output must be valid JSON. Use Python for reliable escaping:
json_replace() {
local content="$1"
python3 -c "
import json, sys
content = sys.stdin.read()
print(json.dumps({'replace': content}))
" <<< "$content"
}
Common pitfall: Bash string escaping breaks on newlines and special characters.
State Persistence
Each hook invocation is stateless. State must persist to disk:
update_state() {
local field="$1"
local value="$2"
python3 -c "
import json
with open('$STATE_FILE', 'r+') as f:
data = json.load(f)
data['$field'] = $value
f.seek(0)
json.dump(data, f, indent=2)
f.truncate()
"
}
Test Mode Detection
Check state file to determine if tests are running:
is_test_mode() {
[[ -f "$STATE_FILE" ]] || return 1
local mode=$(python3 -c "
import json
with open('$STATE_FILE') as f:
print(json.load(f).get('mode', ''))
")
[[ "$mode" == "running" ]]
}
Test Execution Flow
1. User: /run-tests
└─> Initializes state, sets mode=running
2. User: "next"
└─> Hook intercepts
└─> Runner returns test action
└─> Hook replaces prompt with action
└─> Claude executes test
3. User: "validate <response>"
└─> Hook intercepts
└─> Runner validates against expectations
└─> Records PASS/FAIL
└─> Advances to next test
4. Repeat steps 2-3 until all tests complete
5. User: "report"
└─> Runner generates summary
Extension Points
Adding New Commands
Extend the wrapper to handle new commands:
case "$prompt" in
retry)
# Re-run current test
output=$("$RUNNER" retry)
json_replace "$output"
;;
esac
Custom Validation
Add validation types in runner:
if 'json_path' in exp:
# JSONPath validation
import jsonpath_ng
matches = jsonpath_ng.parse(exp['json_path']).find(response)
if not matches:
failures.append(f"JSONPath not found: {exp['json_path']}")
Pre/Post Test Hooks
Add lifecycle hooks in runner:
run_pre_test_hook() {
if [[ -x "$HOOKS_DIR/pre-test.sh" ]]; then
"$HOOKS_DIR/pre-test.sh" "$current_test_id"
fi
}
Additional Resources
Reference Files
references/state-management.md- Test state file schema and management