Hooks自动化钩子
> **时效说明**:本文内容以 2026 年 3 月为基准。
Claude Code:Hooks:自动化执行钩子
时效说明:本文内容以 2026 年 3 月为基准。
9.1 Hooks——把AI嵌进工程流程
Hooks是Claude Code里的事件驱动钩子系统。简单说就是:你在某个时机点挂一段脚本,Claude每次触发这个时机就自动跑一遍。
这个概念对Java开发者来说非常熟悉。Spring的@Before、@AfterAOP切面,Maven的pre-test、post-package生命周期钩子,git的pre-commit、pre-push脚本——Hooks干的是同一件事,只不过切面对象变成了AI的操作行为。
为什么要用Hooks?因为很多工程质量保障的事,你不想靠自觉,也不想每次手动触发。让AI改完代码自动跑Checkstyle,让AI准备push前自动跑单测,这些流程嵌进去之后你就不用操心了。
Hooks在整个Claude Code体系里的定位是"无声的卫兵"——你设置一次,之后它一直在后台默默工作,Claude做任何操作都过一遍你的规则检查。工作量归零之后,工程规范才能真正落地。
9.2 支持哪些事件
Claude Code目前支持26种事件,覆盖从用户输入到工具调用的完整生命周期。
9.2.1 核心事件类型
PreToolUse:Claude即将调用某个工具之前触发。这是最有用的一个,因为它可以拦截——你的脚本如果返回deny,Claude就会放弃这次工具调用。适合做危险操作拦截、权限检查、参数改写。
PostToolUse:工具调用完成之后触发。用来做后置检查,比如Claude修改了文件之后自动跑静态分析、自动格式化、自动记录日志。
PostToolUseFailure:工具调用失败之后触发。可以在这里记录错误、做告警通知或者自动重试逻辑。
UserPromptSubmit:用户提交prompt的时候触发。可以在这里注入额外上下文,或者做内容审核,或者把用户的问题先做一次预处理。
PreAgentAction:Agent开始执行一个大动作之前触发。比如Claude决定"我要重构这个模块"这种高层决策,可以在这里拦截让用户确认。
PostAgentAction:Agent完成一个大动作之后触发。
SessionStart:会话开始或恢复时触发。可以在这里初始化一些上下文,比如自动加载当前git分支信息、项目环境状态。
SessionEnd:会话终止时触发。适合做清理工作,比如把本次会话的操作日志归档。
StopFailure:因API错误导致会话结束时触发。可以在这里做错误告警或者自动恢复尝试。
Notification:Claude Code发送通知时触发。可以把通知转发到Slack、钉钉、邮件等渠道。
PreBashCommand:执行Bash命令之前触发(比PreToolUse更细粒度的Bash专用事件)。
PostBashCommand:Bash命令执行完之后触发。
PreFileWrite:写文件之前触发。可以在这里检查目标路径是否合法,或者注入文件头。
PostFileWrite:文件写入完成之后触发。适合做格式化、静态检查。
PreFileRead:读文件之前触发。
PostFileRead:读文件之后触发。
PreSearch:搜索操作之前触发。
PostSearch:搜索操作完成之后触发。
SubagentStart:子代理启动时触发。适合记录子任务的开始,或者给子代理注入初始上下文。
SubagentStop:子代理完成时触发。适合汇总子任务的结果或做后续处理。
TaskCreated:任务通过TaskCreate创建时触发。可以在这里记录任务信息或者发送创建通知。
TaskCompleted:任务被标记为完成时触发。适合做任务完成后的收尾工作或通知。
TeammateIdle:Agent团队成员即将进入空闲状态时触发。用于协调多Agent工作流中的任务分配。
InstructionsLoaded:CLAUDE.md或rules/*.md加载时触发。可以在规则加载后做一些初始化工作。
ConfigChange:配置文件变更时触发。
CwdChanged:工作目录变更(如执行cd命令)时触发。
FileChanged:监视的文件发生变化时触发。适合做文件监控和自动响应。
WorktreeCreate:创建worktree时触发。
WorktreeRemove:移除worktree时触发。
PreCompact:上下文压缩前触发。可以在这里保存需要在压缩后继续可用的关键信息。
PostCompact:上下文压缩完成后触发。可以在这里恢复或验证压缩后的上下文状态。
Elicitation:MCP服务器请求用户输入时触发。可以在这里自动填充或验证输入。
ElicitationResult:用户响应MCP elicitation后、结果发回服务器前触发。可以在这里对用户输入做后处理。
9.2.2 哪些事件最常用
实际工程中,用得最多的是三个:
PostToolUse + Write + *.java:改完Java文件触发检查PreToolUse + Bash + git push:push前触发测试PreToolUse + Bash + dangerous:拦截危险Shell命令
其他事件用得相对少,但SessionStart用来自动初始化上下文也挺实用。
9.3 三种Hook类型
Hook本身有三种执行方式,选哪种取决于你的检查逻辑有多复杂。
9.3.1 command类型
直接跑一段Shell脚本,这是最常用的。脚本能读环境变量,也能拿到Claude的操作上下文(以JSON格式通过stdin传入)。性能最好,适合绝大多数场景。
{
"type": "command",
"command": "python3 /path/to/check.py"
}
9.3.2 prompt类型
让另一个LLM来判断,适合需要语义理解的场景,比如"检查这个改动是否符合公司安全规范"——规则太复杂没法写成脚本,就交给LLM来判断。代价是耗时较长,一次判断要等LLM返回结果。
{
"type": "prompt",
"prompt": "检查以下代码改动是否存在SQL注入风险,如果有风险,返回 deny 并说明原因"
}
9.3.3 agent类型
启动一个完整的子Agent来做验证,适合需要多步骤检查的重型场景——比如先跑测试、再做安全扫描、最后验证文档是否同步更新。代价是耗时最长,一般只在CI/CD流程里用,或者用在不那么频繁触发的事件上。
{
"type": "agent",
"agent": "security-auditor"
}
9.4 stdin/stdout通信协议详解
Hook脚本和Claude之间通过stdin/stdout通信,这是整个Hooks机制的核心。理解这个协议,你才能写出真正灵活的Hook脚本。
9.4.1 Claude传入的JSON结构
Claude在触发Hook时,会把当前操作的完整上下文以JSON格式写入你脚本的stdin。下面是PreToolUse事件传入的典型JSON结构:
{
"event": "PreToolUse",
"tool_name": "Bash",
"input": {
"command": "rm -rf /tmp/old-build"
},
"session": {
"session_id": "abc123",
"project_path": "/Users/you/myproject"
},
"context": {
"conversation_id": "conv456",
"message_count": 12
}
}
PostToolUse事件传入的JSON还会多一个output字段:
{
"event": "PostToolUse",
"tool_name": "Write",
"input": {
"file_path": "/src/main/java/UserService.java",
"content": "..."
},
"output": {
"success": true,
"bytes_written": 2048
},
"session": {
"session_id": "abc123",
"project_path": "/Users/you/myproject"
}
}
Write工具的input里有file_path和content,Bash工具的input里有command,Read工具的input里有file_path。这些字段是你做条件判断的数据来源。
9.4.2 脚本的返回值协议
你的脚本向stdout输出的内容决定了Claude的下一步行动。
什么都不输出(或者输出空字符串):Hook检查通过,Claude继续执行原本的操作。这是最常见的情况。
输出纯文本:Claude会把这段文本作为额外信息,加入到它的上下文里,继续执行原操作。适合给Claude提供额外参考信息,比如"注意:这个类在生产环境中有高频调用"。
输出JSON,包含action: "deny":Claude放弃这次工具调用,并把reason字段的内容显示给用户。
{"action": "deny", "reason": "危险命令已拦截:rm -rf 操作需要人工确认"}
输出JSON,包含action: "updatedInput":Claude用你修改过的参数来替代原始参数执行工具。这个能力很强大,后面单独讲。
{"action": "updatedInput", "input": {"command": "rm -rf /tmp/old-build-safe"}}
输出JSON,包含action: "exit":直接中止整个会话,适合检测到严重违规时用。
9.4.3 读取stdin的Python模板
下面是一个读取stdin、做判断、返回结果的Python脚本模板,实际开发Hook脚本的时候可以直接套用:
#!/usr/bin/env python3
import json
import sys
def main():
# 从stdin读取Claude传入的上下文
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
# JSON解析失败,不干预,让Claude继续
sys.exit(0)
event = data.get('event', '')
tool_name = data.get('tool_name', '')
input_data = data.get('input', {})
# 你的检查逻辑写在这里
if tool_name == 'Bash':
command = input_data.get('command', '')
# 检查危险命令
dangerous_patterns = [
'rm -rf /',
'rm -rf ~',
'DROP DATABASE',
'truncate table',
'format c:',
]
for pattern in dangerous_patterns:
if pattern.lower() in command.lower():
result = {
"action": "deny",
"reason": f"危险操作已拦截(包含 '{pattern}'),请手动确认后执行"
}
print(json.dumps(result, ensure_ascii=False))
return
# 检查通过,不输出任何内容(Claude继续正常执行)
if __name__ == '__main__':
main()
这个模板有几个注意点:
sys.exit(0)和 "什么都不输出" 效果一样,都是放行- 错误处理要写完整,脚本崩溃比Hook不触发更麻烦
- 如果你要输出纯文本而不是JSON,直接
print("文本内容")就行,Claude会把它作为上下文信息
9.5 PreToolUse的两种特殊返回值
PreToolUse比PostToolUse多了两种能改变Claude行为的返回值:deny拦截和updatedInput改写。
9.5.1 deny:拦截工具调用
当你的Hook脚本返回deny时,Claude会放弃这次工具调用,就好像没有发生过一样。Claude会收到你的reason作为反馈,通常它会把这个信息展示给用户,然后等待下一步指令。
完整示例——检查Bash命令里有没有硬编码密码:
#!/usr/bin/env python3
import json
import sys
import re
def check_hardcoded_secrets(command: str) -> str | None:
"""检查命令里是否包含疑似硬编码的密钥"""
patterns = [
# 常见密码参数模式
r'(?i)(password|passwd|pwd|secret|token|apikey|api_key)\s*[=:]\s*["\']?[A-Za-z0-9+/]{8,}',
# AWS风格的密钥
r'AKIA[0-9A-Z]{16}',
# 看起来像JWT的字符串
r'eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}',
]
for pattern in patterns:
match = re.search(pattern, command)
if match:
return f"疑似硬编码凭证(匹配到:{match.group()[:20]}...)"
return None
def main():
try:
data = json.load(sys.stdin)
except:
sys.exit(0)
if data.get('tool_name') != 'Bash':
sys.exit(0)
command = data.get('input', {}).get('command', '')
issue = check_hardcoded_secrets(command)
if issue:
result = {
"action": "deny",
"reason": f"[安全检查] 已拦截:{issue}。请使用环境变量代替明文凭证。"
}
print(json.dumps(result, ensure_ascii=False))
return
if __name__ == '__main__':
main()
对应的Hook配置:
{
"hooks": {
"PreToolUse": [
{
"matcher": {
"tool_name": "Bash"
},
"type": "command",
"command": "python3 ~/.claude/hooks/check-secrets.py"
}
]
}
}
9.5.2 updatedInput:改写工具参数
updatedInput是一个更"悄悄"的能力。Claude不会感知到自己的参数被修改了,它直接用你改写后的参数执行工具。
典型场景是文件路径规范化。如果Claude创建文件时经常把路径搞错,你可以写一个Hook自动修正:
#!/usr/bin/env python3
import json
import sys
import os
# 项目的标准目录规范
JAVA_SOURCE_ROOT = "src/main/java"
TEST_SOURCE_ROOT = "src/test/java"
RESOURCE_ROOT = "src/main/resources"
def normalize_java_path(file_path: str, project_root: str) -> str:
"""自动规范化Java文件路径"""
# 如果Claude直接写到根目录,自动移到正确的源码目录
if file_path.endswith('.java') and not JAVA_SOURCE_ROOT in file_path:
basename = os.path.basename(file_path)
# 猜测是测试类还是主类
if 'Test' in basename or 'test' in file_path.lower():
return os.path.join(project_root, TEST_SOURCE_ROOT, basename)
else:
return os.path.join(project_root, JAVA_SOURCE_ROOT, basename)
return file_path
def main():
try:
data = json.load(sys.stdin)
except:
sys.exit(0)
if data.get('tool_name') != 'Write':
sys.exit(0)
input_data = data.get('input', {})
file_path = input_data.get('file_path', '')
project_root = data.get('session', {}).get('project_path', '.')
normalized = normalize_java_path(file_path, project_root)
if normalized != file_path:
# 路径发生了变化,返回修正后的参数
new_input = dict(input_data)
new_input['file_path'] = normalized
result = {
"action": "updatedInput",
"input": new_input
}
print(json.dumps(result))
print(f"[Hook] 路径已自动规范化:{file_path} → {normalized}", file=sys.stderr)
if __name__ == '__main__':
main()
注意:updatedInput要求你返回完整的input对象,不能只返回你修改的那几个字段。漏掉其他字段会导致工具调用失败。
9.6 实战Hook场景集合
下面是几个在Java项目里真实有用的Hook场景,直接拿走用。
9.6.1 自动添加版权头(新建Java文件时)
新建Java文件时,自动在文件顶部插入公司版权声明。这种事让工程师手动加很难保证,但Hook可以强制执行:
#!/usr/bin/env python3
# ~/.claude/hooks/add-copyright.py
import json
import sys
from datetime import datetime
COPYRIGHT_TEMPLATE = """\
/*
* Copyright (c) {year} Your Company Name. All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying of this file, via any medium is strictly prohibited.
*/
"""
def main():
try:
data = json.load(sys.stdin)
except:
sys.exit(0)
if data.get('tool_name') != 'Write':
sys.exit(0)
input_data = data.get('input', {})
file_path = input_data.get('file_path', '')
content = input_data.get('content', '')
# 只处理Java文件
if not file_path.endswith('.java'):
sys.exit(0)
# 如果已经有版权头,不重复添加
if 'Copyright' in content[:200]:
sys.exit(0)
# 在内容前面插入版权头
copyright_text = COPYRIGHT_TEMPLATE.format(year=datetime.now().year)
new_content = copyright_text + content
new_input = dict(input_data)
new_input['content'] = new_content
result = {
"action": "updatedInput",
"input": new_input
}
print(json.dumps(result))
if __name__ == '__main__':
main()
Hook配置:
{
"hooks": {
"PreToolUse": [
{
"matcher": {
"tool_name": "Write",
"file_pattern": "**/*.java"
},
"type": "command",
"command": "python3 ~/.claude/hooks/add-copyright.py"
}
]
}
}
9.6.2 防止提交包含TODO的代码
团队规范:TODO必须有对应的JIRA ticket号,格式是TODO(PROJ-123)。没有ticket号的TODO不能提交:
#!/usr/bin/env python3
# ~/.claude/hooks/check-todo.py
import json
import sys
import re
import subprocess
def main():
try:
data = json.load(sys.stdin)
except:
sys.exit(0)
if data.get('tool_name') != 'Bash':
sys.exit(0)
command = data.get('input', {}).get('command', '')
# 只拦截git commit操作
if 'git commit' not in command:
sys.exit(0)
# 检查暂存区里有没有裸TODO
try:
result = subprocess.run(
['git', 'diff', '--cached', '--unified=0'],
capture_output=True, text=True, timeout=10
)
diff_output = result.stdout
except Exception as e:
# 检查失败,放行(不能因为Hook出错就阻断工作流)
sys.exit(0)
# 找出新增的TODO行(+开头)
bare_todos = []
for line in diff_output.split('\n'):
if line.startswith('+') and not line.startswith('+++'):
# 有TODO但没有ticket号
if re.search(r'TODO(?!\s*\()', line, re.IGNORECASE):
bare_todos.append(line[1:].strip())
if bare_todos:
examples = '\n'.join(f' {t}' for t in bare_todos[:3])
suffix = f'\n ...(共{len(bare_todos)}处)' if len(bare_todos) > 3 else ''
result = {
"action": "deny",
"reason": (
f"[提交检查] 发现 {len(bare_todos)} 处不规范的TODO:\n"
f"{examples}{suffix}\n\n"
"请改为 TODO(PROJ-123): 描述 的格式,或者先处理掉这些TODO。"
)
}
print(json.dumps(result, ensure_ascii=False))
if __name__ == '__main__':
main()
9.6.3 自动格式化(用Google Java Format)
Claude写完Java文件之后,自动用Google Java Format做格式化,确保代码风格统一:
#!/bin/bash
# ~/.claude/hooks/auto-format-java.sh
# 从stdin读取Claude传入的JSON
INPUT=$(cat)
# 解析文件路径
FILE_PATH=$(echo "$INPUT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('input',{}).get('file_path',''))" 2>/dev/null)
# 只处理Java文件
if [[ "$FILE_PATH" != *.java ]]; then
exit 0
fi
# 检查文件是否存在
if [ ! -f "$FILE_PATH" ]; then
exit 0
fi
# 运行Google Java Format
# 需要提前下载:https://github.com/google/google-java-format/releases
GJF_JAR="$HOME/.local/bin/google-java-format.jar"
if [ -f "$GJF_JAR" ]; then
java -jar "$GJF_JAR" --replace "$FILE_PATH" 2>/dev/null
if [ $? -eq 0 ]; then
echo "✓ 已自动格式化:$FILE_PATH"
fi
fi
对应的PostToolUse配置:
{
"hooks": {
"PostToolUse": [
{
"matcher": {
"tool_name": "Write",
"file_pattern": "**/*.java"
},
"type": "command",
"command": "bash ~/.claude/hooks/auto-format-java.sh"
}
]
}
}
9.6.4 记录所有AI操作日志
把Claude的每一次工具调用都记录到文件,方便后续审计和复盘:
#!/usr/bin/env python3
# ~/.claude/hooks/audit-log.py
import json
import sys
import os
from datetime import datetime
LOG_DIR = os.path.expanduser("~/.claude/audit-logs")
os.makedirs(LOG_DIR, exist_ok=True)
def main():
try:
raw = sys.stdin.read()
data = json.loads(raw)
except:
sys.exit(0)
# 日志文件按日期分割
today = datetime.now().strftime('%Y-%m-%d')
log_file = os.path.join(LOG_DIR, f"{today}.jsonl")
# 构建日志条目
log_entry = {
"timestamp": datetime.now().isoformat(),
"event": data.get('event'),
"tool_name": data.get('tool_name'),
"session_id": data.get('session', {}).get('session_id'),
"project_path": data.get('session', {}).get('project_path'),
"input_summary": _summarize_input(data.get('input', {})),
}
# 追加写入日志文件(JSONL格式,每行一条)
with open(log_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
def _summarize_input(input_data: dict) -> dict:
"""对敏感或超大的input字段做脱敏/截断"""
summary = {}
for key, value in input_data.items():
if isinstance(value, str):
if key == 'content':
# 文件内容只记录前100字符
summary[key] = value[:100] + ('...' if len(value) > 100 else '')
elif key in ('password', 'token', 'secret', 'api_key'):
summary[key] = '***REDACTED***'
else:
summary[key] = value
else:
summary[key] = value
return summary
if __name__ == '__main__':
main()
这个Hook挂在PostToolUse上,不拦截任何操作,纯记录:
{
"hooks": {
"PostToolUse": [
{
"matcher": {},
"type": "command",
"command": "python3 ~/.claude/hooks/audit-log.py"
}
]
}
}
"matcher": {}表示匹配所有工具调用。日志会写到~/.claude/audit-logs/2026-03-29.jsonl,每行一条JSON,方便用jq查询。
9.6.5 检查API Key是否被硬编码
防止Claude或者自己不小心把API Key写进代码里提交出去:
#!/usr/bin/env python3
# ~/.claude/hooks/check-api-keys.py
import json
import sys
import re
# 各种API Key的正则模式
SECRET_PATTERNS = {
'AWS Access Key': r'AKIA[0-9A-Z]{16}',
'AWS Secret Key': r'(?i)aws.{0,20}secret.{0,20}[0-9a-zA-Z/+]{40}',
'GitHub Token': r'ghp_[0-9a-zA-Z]{36}',
'GitHub OAuth': r'gho_[0-9a-zA-Z]{36}',
'OpenAI API Key': r'sk-[0-9a-zA-Z]{48}',
'Anthropic API Key': r'sk-ant-[0-9a-zA-Z\-]{90,}',
'Slack Token': r'xox[baprs]-[0-9a-zA-Z\-]{10,}',
'Stripe Secret Key': r'sk_live_[0-9a-zA-Z]{24}',
'Generic High-Entropy': r'(?i)(api_key|apikey|secret_key|access_token)\s*[=:]\s*["\']?[0-9a-zA-Z/+\-_]{32,}',
}
def scan_for_secrets(text: str) -> list:
"""扫描文本中的敏感信息"""
findings = []
for pattern_name, pattern in SECRET_PATTERNS.items():
matches = re.finditer(pattern, text)
for match in matches:
masked = match.group()[:8] + '***'
findings.append(f"{pattern_name}: {masked}")
return findings
def main():
try:
data = json.load(sys.stdin)
except:
sys.exit(0)
tool_name = data.get('tool_name', '')
input_data = data.get('input', {})
# 只检查写文件操作
if tool_name != 'Write':
sys.exit(0)
file_path = input_data.get('file_path', '')
content = input_data.get('content', '')
# 不检查.env文件(本来就是放密钥的)
# 不检查测试资源文件(可能有mock数据)
skip_patterns = ['.env', '.env.example', 'test', 'mock', 'fixture']
if any(p in file_path.lower() for p in skip_patterns):
sys.exit(0)
findings = scan_for_secrets(content)
if findings:
findings_text = '\n'.join(f' - {f}' for f in findings)
result = {
"action": "deny",
"reason": (
f"[安全检查] 文件 {file_path} 中发现疑似硬编码的密钥:\n"
f"{findings_text}\n\n"
"请改用环境变量:System.getenv(\"API_KEY\") 或 @Value(\"${api.key}\")"
)
}
print(json.dumps(result, ensure_ascii=False))
if __name__ == '__main__':
main()
9.7 完整的settings.json配置示例
把上面几个Hook都装上之后,完整的settings.json大概是这样:
{
"hooks": {
"PreToolUse": [
{
"matcher": {
"tool_name": "Bash"
},
"type": "command",
"command": "python3 ~/.claude/hooks/check-secrets.py"
},
{
"matcher": {
"tool_name": "Bash",
"command_pattern": "git commit.*"
},
"type": "command",
"command": "python3 ~/.claude/hooks/check-todo.py"
},
{
"matcher": {
"tool_name": "Bash",
"command_pattern": "git push.*"
},
"type": "command",
"command": "mvn test -q && echo '{}' || python3 -c \"import json; print(json.dumps({'action': 'deny', 'reason': '单元测试未通过,push已拦截'}))\""
},
{
"matcher": {
"tool_name": "Write",
"file_pattern": "**/*.java"
},
"type": "command",
"command": "python3 ~/.claude/hooks/add-copyright.py"
},
{
"matcher": {
"tool_name": "Write"
},
"type": "command",
"command": "python3 ~/.claude/hooks/check-api-keys.py"
}
],
"PostToolUse": [
{
"matcher": {
"tool_name": "Write",
"file_pattern": "**/*.java"
},
"type": "command",
"command": "mvn checkstyle:check -q 2>&1 | tail -20"
},
{
"matcher": {
"tool_name": "Write",
"file_pattern": "**/*.java"
},
"type": "command",
"command": "bash ~/.claude/hooks/auto-format-java.sh"
},
{
"matcher": {}
},
{
"type": "command",
"command": "python3 ~/.claude/hooks/audit-log.py"
}
]
}
}
注意:同一个事件可以挂多个Hook,Claude会按顺序依次执行。PreToolUse里任意一个返回deny,操作就被拦截。
9.8 Hook脚本的调试方法
Hook不触发或者行为不对,是新手踩坑最多的地方。以下是系统化的排查思路。
9.8.1 第一步:确认配置文件语法
最常见的问题是JSON语法错误导致整个Hooks配置失效。运行这个命令验证:
python3 -c "import json; json.load(open('.claude/settings.json')); print('JSON语法正确')"
如果有报错,按报错信息找对应的行号修复。常见错误:trailing comma(最后一个配置项后面多了逗号)。
9.8.2 第二步:手动测试Hook脚本
不要依赖Claude触发,直接手动构造stdin来测试你的脚本:
# 构造一个模拟Claude传入的JSON
echo '{
"event": "PreToolUse",
"tool_name": "Bash",
"input": {
"command": "rm -rf /tmp/test"
}
}' | python3 ~/.claude/hooks/check-secrets.py
# 看看脚本输出了什么
# 如果什么都没输出,说明检查通过(放行)
# 如果输出了JSON,说明触发了拦截
这个方法能确认脚本本身逻辑是对的,跟Claude无关。
9.8.3 第三步:在脚本里写调试日志
import logging
# 写到文件而不是stderr,避免干扰Hook的stdout输出
logging.basicConfig(
filename=os.path.expanduser('~/.claude/hooks/debug.log'),
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
def main():
raw = sys.stdin.read()
logging.debug(f"Hook received: {raw[:500]}") # 记录前500字符
data = json.loads(raw)
logging.debug(f"Parsed: tool={data.get('tool_name')}, event={data.get('event')}")
# ... 你的逻辑
注意:绝对不能把调试信息输出到stdout,因为Claude会把stdout的内容当作Hook的返回指令来解析,随便输出一段文本可能破坏协议。用logging写到文件,或者用sys.stderr输出。
9.8.4 第四步:检查matcher是否匹配
Claude Code的matcher支持的字段是有限的。如果你写了command_pattern但Hook不触发,检查一下这个字段是否真的被支持。可以先把matcher改成匹配所有工具,确认脚本本身能被调到,再缩窄matcher范围。
{
"matcher": {}
}
匹配所有工具调用。确认能触发之后再加tool_name、file_pattern等条件。
9.8.5 常见故障清单
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| Hook完全不触发 | JSON语法错误 | python3 -c "import json; json.load(open(...))" |
| 脚本找不到 | 路径写错,或者脚本没有执行权限 | ls -la ~/.claude/hooks/ 检查文件存在且可执行 |
| deny了但Claude还继续 | stdout输出格式不对,不是有效JSON | `echo '...' |
| 脚本报错崩溃 | Python依赖没装,或者语法错误 | 直接运行脚本,看报错信息 |
| Hook触发了但效果不对 | 写了内容到stdout但不是预期的JSON格式 | 在~/.claude/hooks/debug.log里看记录 |
9.9 Hook的性能注意事项
Hook在Claude工作的过程中同步执行——每次工具调用,Claude都要等你的Hook脚本跑完才继续。脚本太慢会直接让整个体验变卡。
9.9.1 性能基准线
根据实际体验:
- 50ms以内:无感知,跟没有Hook一样
- 50-200ms:轻微停顿,可接受
- 200ms-1s:明显停顿,开始影响体验
- 1s以上:让人烦躁,要么优化要么去掉
9.9.2 常见的性能陷阱
启动Python进程本身就要50-100ms。如果你的Hook脚本只做个简单的字符串匹配,用Shell脚本会比Python快很多:
# 用bash做简单检查,比python快3-5倍
#!/bin/bash
INPUT=$(cat)
if echo "$INPUT" | grep -q '"DROP DATABASE"'; then
echo '{"action": "deny", "reason": "危险SQL已拦截"}'
fi
不要在Hook里发网络请求。每次工具调用都发一个HTTP请求,10ms的延迟乘以100次调用就是1秒的等待。如果必须要网络检查,考虑用异步方式(Hook触发后异步发请求,不等结果)。
Maven/Gradle命令很慢。mvn checkstyle:check冷启动需要1-3秒,放在PostToolUse里每次写文件都跑一遍会很慢。解决办法:
- 只在特定触发词出现时才跑(比如用户说"检查一下")
- 用checkstyle的独立命令行工具,不通过Maven运行
- 用
nohup异步触发,不阻塞Claude
# 异步触发,不等待结果
nohup mvn checkstyle:check -q > /tmp/checkstyle-last.log 2>&1 &
echo "Checkstyle在后台运行,结果写到 /tmp/checkstyle-last.log"
PostToolUse不需要返回任何拦截指令,所以异步执行完全没问题。PreToolUse如果要拦截,就必须同步等待结果。
9.9.3 轻量Hook的写法原则
- 纯文本匹配用Shell,不用Python
- 文件系统操作(读文件、检查路径)是快的,放心用
- 进程调用(调用另一个程序)是慢的,尽量少用
- 有任何网络调用就做成异步
- 每个Hook脚本都要有超时保护(
timeout 5 your_script.py)
加超时保护的Hook配置:
{
"type": "command",
"command": "timeout 5 python3 ~/.claude/hooks/check-secrets.py || echo ''"
}
|| echo ''保证即使超时了,整个Hook也能正常结束,不会卡住Claude。