第 7 章 · 02 示例脚本精读 本节摘要:官方仓库提供了 8 个可直接落地的示例,覆盖 hooks 的主要用途:守门(命令校验、安全扫描、提示词校验)、提效(自动格式化)、质检(智能 Stop 评估)、观测(上下文用量追踪)、工程化(Auto 模式权限种子脚本、学习进度记录)。每个示例都展示了固定的代码骨架:读 stdin JSON → 判断事件/工具 → 处理 → 按 exit-code 协议返回。精读它们,你就掌握了写钩子的全部套路。 学习目标 阅读完本节,你应当能够: 说出 8 个示例分别挂在哪个事件、解决什么问题。 复述钩子脚本的固定骨架(读 stdin → 判断 → 处理 → 退出码)。
本节摘要:官方仓库提供了 8 个可直接落地的示例,覆盖 hooks 的主要用途:守门(命令校验、安全扫描、提示词校验)、提效(自动格式化)、质检(智能 Stop 评估)、观测(上下文用量追踪)、工程化(Auto 模式权限种子脚本、学习进度记录)。每个示例都展示了固定的代码骨架:读 stdin JSON → 判断事件/工具 → 处理 → 按 exit-code 协议返回。精读它们,你就掌握了写钩子的全部套路。
阅读完本节,你应当能够:
/dev/tty 读用户输入。.claude/hooks/validate-bash.py——在 Bash 工具执行前拦截危险命令:
BLOCKED_PATTERNS = [ (r"\brm\s+-rf\s+/", "Blocking dangerous rm -rf / command"), (r"\bsudo\s+rm", "Blocking sudo rm command"), ] def main(): input_data = json.load(sys.stdin) tool_name = input_data.get("tool_name", "") if tool_name != "Bash": sys.exit(0) # 不是 Bash,放行 command = input_data.get("tool_input", {}).get("command", "") for pattern, message in BLOCKED_PATTERNS: if re.search(pattern, command): print(message, file=sys.stderr) # 阻断原因写 stderr sys.exit(2) # exit 2 = 阻断 sys.exit(0) # 通过 = 放行
配置里用 matcher 限定只在 Bash 时触发:"matcher": "Bash"。骨架三件套:过滤工具名 → 正则匹配危险模式 → 命中就 exit 2(原因写 stderr,否则用户看不到为什么被拦)。
.claude/hooks/security-scan.py——每次写文件后扫描硬编码秘密:
SECRET_PATTERNS = [ (r"password\s*=\s*['\"][^'\"]+['\"]", "Potential hardcoded password"), (r"api[_-]?key\s*=\s*['\"][^'\"]+['\"]", "Potential hardcoded API key"), ] def main(): input_data = json.load(sys.stdin) tool_name = input_data.get("tool_name", "") if tool_name not in ["Write", "Edit"]: sys.exit(0) tool_input = input_data.get("tool_input", {}) content = tool_input.get("content", "") or tool_input.get("new_string", "") file_path = tool_input.get("file_path", "") warnings = [] for pattern, message in SECRET_PATTERNS: if re.search(pattern, content, re.IGNORECASE): warnings.append(message) if warnings: output = { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": f"Security warnings for {file_path}: " + "; ".join(warnings) } } print(json.dumps(output)) # 附加上下文,Claude 会看到 sys.exit(0)
注意与示例 1 的差异:PostToolUse 不阻断,而是注入 additionalContext——把警告喂回给 Claude,让它自己决定怎么处理。这体现了两类钩子的分工:执行前"拦",执行后"提醒"。
.claude/hooks/validate-prompt.py——用户提交提示词时就拦截危险意图:
BLOCKED_PATTERNS = [ (r"delete\s+(all\s+)?database", "Dangerous: database deletion"), (r"rm\s+-rf\s+/", "Dangerous: root deletion"), ] def main(): input_data = json.load(sys.stdin) prompt = input_data.get("user_prompt", "") or input_data.get("prompt", "") for pattern, message in BLOCKED_PATTERNS: if re.search(pattern, prompt, re.IGNORECASE): print(json.dumps({"decision": "block", "reason": f"Blocked: {message}"})) sys.exit(0) # block 走 JSON 输出,不是 exit 2 sys.exit(0)
它演示了 UserPromptSubmit 的输出格式:decision: "block" + reason,通过 JSON stdout(exit 0) 返回阻断——与 PreToolUse 的 exit 2 阻断是两条不同的通道。
.claude/hooks/format-code.sh——文件写完后按扩展名自动跑格式化器:
INPUT=$(cat) # 读 stdin TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('tool_name', ''))") FILE_PATH=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('tool_input', {}).get('file_path', ''))") if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then exit 0 fi case "$FILE_PATH" in *.js|*.jsx|*.ts|*.tsx|*.json) command -v prettier &>/dev/null && prettier --write "$FILE_PATH" ;; *.py) command -v black &>/dev/null && black "$FILE_PATH" ;; *.go) command -v gofmt &>/dev/null && gofmt -w "$FILE_PATH" ;; esac exit 0
要点:工具没安装就静默跳过(command -v ... &>/dev/null &&),绝不因钩子失败阻断主流程。
挂在 Stop 事件上,用 LLM 评估任务是否完成:
{ "hooks": { "Stop": [ { "hooks": [ { "type": "prompt", "prompt": "Review if Claude completed all requested tasks. Check: 1) Were all files created/modified? 2) Were there unresolved errors? If incomplete, explain what's missing.", "timeout": 30 } ] } ] } }
LLM 返回结构化决策:{"decision": "approve", "reason": "...", "continue": false, "stopReason": "Task complete"}。这是"停止前最后一关":Claude 每回合结束都会过一遍检查,没做完就继续。配套安全阀:同一个 Stop 钩子连续 block 8 次会强制结束会话(环境变量 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 可调,设 0 关闭)——防止有 bug 的钩子把会话卡成死循环。v2.1.163 起,Stop 钩子还能返回 hookSpecificOutput.additionalContext 注入反馈并继续回合,不再需要走 error-label 通道。
context-tracker.py——一个脚本挂两个事件:UserPromptSubmit 当"消息前"钩子存下当前 token 数,Stop 当"响应后"钩子算差值:
def handle_user_prompt_submit(data): """消息前:保存当前 token 数到临时文件(按 session_id 隔离)""" state_file = get_state_file(session_id) json.dump({"pre_tokens": current_tokens}, open(state_file, "w")) def handle_stop(data): """响应后:读 transcript 算当前 token 数,输出差值""" delta_tokens = current_tokens - pre_tokens remaining = CONTEXT_LIMIT - current_tokens percentage = (current_tokens / CONTEXT_LIMIT) * 100 print(f"Context: ~{current_tokens:,} tokens ({percentage:.1f}% used)", file=sys.stderr)
三个设计点:中间状态存临时文件,按 session_id 隔离避免并发会话串数据;token 计数提供两档(字符估算 ~4 字符/token,零依赖;tiktoken p50k_base ~90-95% 精度);结果打 stderr(非阻断路径,exit 0 下 stderr 只在 verbose 模式显示)。注意 Anthropic 官方未发布离线分词器,两种方法都是近似值。
setup-auto-mode-permissions.py——一次性往 ~/.claude/settings.json 写入约 67 条安全权限规则,等价于 Auto 模式的基线:内置工具(Read/Edit/Write/Glob/Grep/Agent)、git 读操作、git 本地写(add/commit/checkout)、包管理器(npm/pip/cargo)、构建测试(make/pytest/go test)、常见 Shell(ls/cat/find/cp/mv)、GitHub CLI。刻意排除的规则同样重要:rm -rf、sudo、force push、git reset --hard、DROP TABLE、kubectl delete、terraform destroy、curl | bash、生产部署。支持 --dry-run 预览,可重复执行(已存在的规则跳过)。
session-end.sh——会话结束时交互式记录"本次学了哪些模块",写入 ~/.claude-howto-progress.json。它示范了三个关键模式:
read -r INPUT </dev/tty:钩子脚本的 stdin 已被 JSON 载荷占用,交互式读取必须显式指向 /dev/tty 才能碰到终端。CLAUDE_PROJECT_DIR 是否匹配本仓库,避免全局安装后误入无关项目;进度文件放 ~/,git pull 不会覆盖。八个示例覆盖了 hooks 的全部典型用法:守门(PreToolUse 拦命令、PostToolUse 扫秘密、UserPromptSubmit 拦提示词)、提效(写后自动格式化)、质检(Stop 智能检查完成度)、观测(钩子对追踪 token)、工程化(权限基线种子、会话日记)。共同骨架永远是:读 stdin JSON → 按事件/工具分流 → 处理 → 按协议返回。exit 0 放行、exit 2 阻断、JSON stdout 传决策——下一节把这个协议掰开揉碎,并汇总全部踩坑经验。
下一节预告:第 3 节讲 exit-code 协议、JSON 输入输出字段、权限决策优先级与调试方法。