04-Hook 脚本开发与调试
Hook 脚本开发与调试
学习目标
本章将掌握 Claude Code Hook 脚本的开发规范,通过 hookify 和 security-guidance 的实际代码,掌握:
- Hook 的输入输出协议(stdin JSON 输入、stdout/stderr 输出)
- 三种退出码的语义差异
- Python 和 Bash Hook 脚本的实现模式
- 调试工具链的使用
前置知识
项目实践
输入输出协议
输入:Claude Code 通过 stdin 向 Hook 脚本传递 JSON:
{ "session_id": "abc123", "tool_name": "Edit", "tool_input": { "file_path": "/path/to/file.js", "new_string": "console.log('hello')" }}Stop Hook 的输入还包含 transcript_path,指向会话的 JSONL transcript 文件。
读取输入的模式:
# Pythonimport json, sysinput_data = json.loads(sys.stdin.read())tool_name = input_data["tool_name"]tool_input = input_data["tool_input"]# Bashinput=$(cat)transcript_path=$(echo "$input" | jq -r '.transcript_path')输出:
| 输出流 | 内容 | 用途 |
|---|---|---|
| stdout | JSON 响应 | Claude Code 解析决策 |
| stderr | 用户可见的文本消息 | 显示给用户 |
退出码语义
| 退出码 | 含义 | 适用事件 |
|---|---|---|
0 | 允许继续(或 Hook 成功执行) | 所有事件 |
2 | 阻止工具执行 | PreToolUse |
1 | Hook 脚本错误(应避免) | 所有事件 |
关键:即使 Hook 逻辑上要阻止操作,也应以 exit 0 结束(通过 JSON 输出表达决策)。exit 2 仅用于 PreToolUse 的阻止决策。exit 1 表示 Hook 脚本自身出错。
Python Hook 模式(hookify)
#!/usr/bin/env python3import json, sys, os
# 1. 读取输入try: input_data = json.loads(sys.stdin.read())except json.JSONDecodeError: sys.exit(0) # Fail-open
# 2. 确定事件类型tool_name = input_data.get("tool_name", "")if tool_name == "Bash": event = "bash"elif tool_name in ("Edit", "Write", "MultiEdit"): event = "file"else: event = "other"
# 3. 加载规则from core.config_loader import load_rulesrules = load_rules(event=event)
# 4. 评估规则from core.rule_engine import RuleEngineresult = RuleEngine().evaluate_rules(rules, input_data)
# 5. 输出结果print(json.dumps(result))sys.exit(0) # Always exit 0Bash Hook 模式(ralph-wiggum)
#!/bin/bashstate_file=".claude/ralph-loop.local.md"
# 检查状态文件[ ! -f "$state_file" ] && exit 0
# 解析 frontmatteriteration=$(grep "^iteration:" "$state_file" | awk '{print $2}')max_iterations=$(grep "^max_iterations:" "$state_file" | awk '{print $2}')
# 检查最大迭代次数if [ "$iteration" -ge "$max_iterations" ]; then rm -f "$state_file" exit 0fi
# 检查完成承诺# ... 解析 transcript 中的 <promise> 标签 ...
# 如果不完成 → blocknew_iteration=$((iteration + 1))sed -i '' "s/^iteration: $iteration/iteration: $new_iteration/" "$state_file"
# 输出 block JSONjq -n \ --arg reason "$(cat "$state_file")" \ --arg msg "Ralph iteration $new_iteration" \ '{"decision": "block", "reason": $reason, "systemMessage": $msg}'
exit 0调试工具链
plugin-dev 插件提供了 Hook 调试工具:
| 工具 | 功能 |
|---|---|
validate-hook-schema.sh | 验证 hooks.json 的结构是否符合 schema |
test-hook.sh | 用样本 JSON 输入测试 Hook 脚本,验证输出 |
hook-linter.sh | 检查 Hook 脚本的最佳实践(Fail-open、错误处理等) |
使用示例:
# 测试 Hook 脚本./scripts/test-hook.sh plugins/hookify/hooks/pretooluse.py \ --input '{"tool_name": "Edit", "tool_input": {"file_path": "test.js"}}'
# 验证 Hook 注册./scripts/validate-hook-schema.sh plugins/hookify/hooks/hooks.json陷阱与对策
| 陷阱 | 表现 | 对策 |
|---|---|---|
| Hook 脚本抛出未捕获异常 | 退出码非 0,操作被阻止 | 顶层 try/except 捕获所有异常,确保 exit 0 |
| stdin 解析失败 | JSONDecodeError 导致崩溃 | 在解析前检查 stdin 非空,捕获解析异常 |
Bash 中 set -e 导致意外退出 | 任何非零退出码都会终止脚本 | Hook 脚本中不使用 set -e,或使用 ` |
| 正则缓存导致过期规则匹配 | 修改规则后仍然触发旧模式 | 重启会话或手动清理缓存目录 |
| Stop Hook 中 transcript 文件锁定 | 无法读取 JSONL transcript | 使用 grep + tail -1 读取最后一行,避免锁定 |
设计取舍
为什么 Hook 的输入是 JSON 而非命令行参数?
JSON 可以表达嵌套结构(如 tool_input 中的完整工具参数),命令行参数难以传递复杂数据。同时,JSON 是跨语言的标准格式,Python 和 Bash 都有成熟的解析工具。
为什么 Fail-open 是强制原则?
如果 Hook 出错就阻止操作,一个有 bug 的 Hook 会完全锁死 Claude Code。Fail-open 确保 Hook 只在其正确执行时发挥作用,出错时自动放行。
参考来源
- Claude Code Hooks 文档
- 源码:
plugins/hookify/hooks/*.py - 源码:
plugins/security-guidance/hooks/security_reminder_hook.py - 源码:
plugins/ralph-wiggum/hooks/stop-hook.sh - 源码:
plugins/plugin-dev/skills/hook-development/scripts/*.sh