课程0基础Agent开发课 / Claude-Code实战教程 / Hooks自动化钩子
— 36 min read

Hooks自动化钩子

> **时效说明**:本文内容以 2026 年 3 月为基准。

Claude Code:Hooks:自动化执行钩子

时效说明:本文内容以 2026 年 3 月为基准。

9.1 Hooks——把AI嵌进工程流程

Hooks是Claude Code里的事件驱动钩子系统。简单说就是:你在某个时机点挂一段脚本,Claude每次触发这个时机就自动跑一遍。

这个概念对Java开发者来说非常熟悉。Spring的@Before@AfterAOP切面,Maven的pre-testpost-package生命周期钩子,git的pre-commitpre-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传入)。性能最好,适合绝大多数场景。

json
{
  "type": "command",
  "command": "python3 /path/to/check.py"
}

9.3.2 prompt类型

让另一个LLM来判断,适合需要语义理解的场景,比如"检查这个改动是否符合公司安全规范"——规则太复杂没法写成脚本,就交给LLM来判断。代价是耗时较长,一次判断要等LLM返回结果。

json
{
  "type": "prompt",
  "prompt": "检查以下代码改动是否存在SQL注入风险,如果有风险,返回 deny 并说明原因"
}

9.3.3 agent类型

启动一个完整的子Agent来做验证,适合需要多步骤检查的重型场景——比如先跑测试、再做安全扫描、最后验证文档是否同步更新。代价是耗时最长,一般只在CI/CD流程里用,或者用在不那么频繁触发的事件上。

json
{
  "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结构:

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字段:

json
{
  "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_pathcontent,Bash工具的input里有command,Read工具的input里有file_path。这些字段是你做条件判断的数据来源。

9.4.2 脚本的返回值协议

你的脚本向stdout输出的内容决定了Claude的下一步行动。

什么都不输出(或者输出空字符串):Hook检查通过,Claude继续执行原本的操作。这是最常见的情况。

输出纯文本:Claude会把这段文本作为额外信息,加入到它的上下文里,继续执行原操作。适合给Claude提供额外参考信息,比如"注意:这个类在生产环境中有高频调用"。

输出JSON,包含action: "deny":Claude放弃这次工具调用,并把reason字段的内容显示给用户。

json
{"action": "deny", "reason": "危险命令已拦截:rm -rf 操作需要人工确认"}

输出JSON,包含action: "updatedInput":Claude用你修改过的参数来替代原始参数执行工具。这个能力很强大,后面单独讲。

json
{"action": "updatedInput", "input": {"command": "rm -rf /tmp/old-build-safe"}}

输出JSON,包含action: "exit":直接中止整个会话,适合检测到严重违规时用。

9.4.3 读取stdin的Python模板

下面是一个读取stdin、做判断、返回结果的Python脚本模板,实际开发Hook脚本的时候可以直接套用:

python
#!/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()

这个模板有几个注意点:

  1. sys.exit(0) 和 "什么都不输出" 效果一样,都是放行
  2. 错误处理要写完整,脚本崩溃比Hook不触发更麻烦
  3. 如果你要输出纯文本而不是JSON,直接print("文本内容")就行,Claude会把它作为上下文信息

9.5 PreToolUse的两种特殊返回值

PreToolUse比PostToolUse多了两种能改变Claude行为的返回值:deny拦截和updatedInput改写。

9.5.1 deny:拦截工具调用

当你的Hook脚本返回deny时,Claude会放弃这次工具调用,就好像没有发生过一样。Claude会收到你的reason作为反馈,通常它会把这个信息展示给用户,然后等待下一步指令。

完整示例——检查Bash命令里有没有硬编码密码:

python
#!/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配置:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": {
          "tool_name": "Bash"
        },
        "type": "command",
        "command": "python3 ~/.claude/hooks/check-secrets.py"
      }
    ]
  }
}

9.5.2 updatedInput:改写工具参数

updatedInput是一个更"悄悄"的能力。Claude不会感知到自己的参数被修改了,它直接用你改写后的参数执行工具。

典型场景是文件路径规范化。如果Claude创建文件时经常把路径搞错,你可以写一个Hook自动修正:

python
#!/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可以强制执行:

python
#!/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配置:

json
{
  "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不能提交:

python
#!/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做格式化,确保代码风格统一:

bash
#!/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配置:

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": {
          "tool_name": "Write",
          "file_pattern": "**/*.java"
        },
        "type": "command",
        "command": "bash ~/.claude/hooks/auto-format-java.sh"
      }
    ]
  }
}

9.6.4 记录所有AI操作日志

把Claude的每一次工具调用都记录到文件,方便后续审计和复盘:

python
#!/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上,不拦截任何操作,纯记录:

json
{
  "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写进代码里提交出去:

python
#!/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大概是这样:

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配置失效。运行这个命令验证:

bash
python3 -c "import json; json.load(open('.claude/settings.json')); print('JSON语法正确')"

如果有报错,按报错信息找对应的行号修复。常见错误:trailing comma(最后一个配置项后面多了逗号)。

9.8.2 第二步:手动测试Hook脚本

不要依赖Claude触发,直接手动构造stdin来测试你的脚本:

bash
# 构造一个模拟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 第三步:在脚本里写调试日志

python
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范围。

json
{
  "matcher": {}
}

匹配所有工具调用。确认能触发之后再加tool_namefile_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
# 用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里每次写文件都跑一遍会很慢。解决办法:

  1. 只在特定触发词出现时才跑(比如用户说"检查一下")
  2. 用checkstyle的独立命令行工具,不通过Maven运行
  3. nohup异步触发,不阻塞Claude
bash
# 异步触发,不等待结果
nohup mvn checkstyle:check -q > /tmp/checkstyle-last.log 2>&1 &
echo "Checkstyle在后台运行,结果写到 /tmp/checkstyle-last.log"

PostToolUse不需要返回任何拦截指令,所以异步执行完全没问题。PreToolUse如果要拦截,就必须同步等待结果。

9.9.3 轻量Hook的写法原则

  1. 纯文本匹配用Shell,不用Python
  2. 文件系统操作(读文件、检查路径)是快的,放心用
  3. 进程调用(调用另一个程序)是慢的,尽量少用
  4. 有任何网络调用就做成异步
  5. 每个Hook脚本都要有超时保护(timeout 5 your_script.py

加超时保护的Hook配置:

json
{
  "type": "command",
  "command": "timeout 5 python3 ~/.claude/hooks/check-secrets.py || echo ''"
}

|| echo ''保证即使超时了,整个Hook也能正常结束,不会卡住Claude。


本页目录