1. 项目概述:为AI编程助手构建统一的钩子管理工具

如果你和我一样,在日常开发中深度使用Claude Code、Cursor这类AI编程助手,那你一定遇到过这样的场景:想给AI助手加个“紧箍咒”,让它执行 rm -rf / 之前先问问你,或者在它修改关键配置文件时自动触发一次代码检查。这种需求催生了“钩子”机制——让开发者能在AI助手执行关键操作(如运行命令、编辑文件)前后,插入自定义的脚本逻辑。

然而,当你尝试为不同的AI助手编写钩子时,会发现一个令人头疼的问题: 每个平台的钩子接口都长得不一样 。Claude Code的 PreToolUse 事件,在Codex里也叫 PreToolUse ,但传入的数据结构、输出的响应格式、甚至配置文件的写法都大相径庭。更别提Gemini CLI和Cursor了,它们各有各的命名习惯和字段定义。这意味着,为一个平台写的安全检查和业务逻辑,很难直接复用到另一个平台。

agent-hook-schemas 这个npm包,就是为了解决这个“方言”问题而生的。它本质上是一个基于Zod的TypeScript类型定义和工具函数库,为Claude Code、OpenAI Codex、Gemini CLI和Cursor这四大主流AI编程助手的钩子系统,提供了一套统一的、强类型的开发接口。你可以把它理解为AI钩子领域的“ORM”——它把不同平台五花八门的JSON格式,映射成一套你熟悉的、类型安全的TypeScript对象,让你能用同一套思维模型和代码逻辑,去处理所有平台的钩子开发。

1.1 核心价值:从混乱到统一

在没有这个库之前,开发一个跨平台的钩子脚本是怎样的体验?你需要分别阅读四个平台的官方文档(如果它们有文档的话),然后写一堆 if-else 来判断当前是哪个平台,再手动解析不同结构的JSON。更痛苦的是类型安全——你只能把 stdin 解析成 any ,然后祈祷自己没拼错字段名。

agent-hook-schemas 带来的核心价值是 标准化 类型安全

标准化 体现在它抽象出了一套共通的模型。无论底层平台如何命名事件(是 PreToolUse 还是 BeforeTool ),这个库都为你提供了统一的解析函数(如 ParseHookInput )和输出构建函数(如 HookCommandOutputSchema )。你不再需要关心平台差异,只需关注业务逻辑:“当AI要执行命令时,我该如何判断?”

类型安全 则是由Zod和TypeScript共同保障的。所有解析后的数据都是完全类型化的。例如,解析一个 PreToolUse 事件后,你可以安全地访问 input.tool_name (一定是字符串)和 input.tool_input (其结构会根据 tool_name 被自动推断)。如果你尝试访问一个不存在的字段,TypeScript编译器会在写代码时就报错,而不是等到运行时才崩溃。

此外,这个库还解决了配置管理的难题。Claude Code、Codex等都支持多层级配置(用户级、项目级、本地级),合并这些配置并确定最终生效的钩子规则是个繁琐的过程。 agent-hook-schemas 提供了 mergeClaudeHooksFiles 等开箱即用的合并函数,以及 resolveMatchingClaudeHandlers 这样的解析器,能帮你自动计算出针对当前事件应该运行哪些钩子脚本。

简单来说,这个库让AI钩子开发从“手工作坊”进入了“工业化”阶段。无论是想构建企业级的AI操作审计系统,还是仅仅想为自己常用的几个AI助手统一添加一些安全规则,它都能极大地降低你的开发成本和维护负担。

2. 核心架构与设计哲学

要理解 agent-hook-schemas 怎么用,得先摸清它的设计思路。这个库不是简单地把四个平台的类型定义打包在一起,而是经过精心设计,形成了一套层次清晰、易于扩展的架构。

2.1 模块化设计:按平台与功能分离

打开包的 node_modules 目录,你会发现它的导出结构非常清晰,采用了现代JavaScript包常见的“子路径导出”模式。

agent-hook-schemas/
├── index.js                 # 主入口(默认导出Claude相关功能)
├── claude/                  # Claude Code专属
├── codex/                  # OpenAI Codex专属
├── gemini/                 # Gemini CLI专属
├── cursor/                 # Cursor专属
├── claude-hooks-integration/ # Claude配置合并与解析
├── codex-tasks/            # Codex任务管理相关
└── gemini-hooks-integration/ # Gemini配置合并与解析

这种设计的好处显而易见:

  1. 按需引入,减小体积 :如果你只开发Claude Code的钩子,就只导入 agent-hook-schemas/claude ,打包工具可以轻松地做Tree Shaking,避免引入无用代码。
  2. 职责清晰,避免污染 :每个平台的独特逻辑被封装在各自的命名空间下。比如,Codex有严格( strict )的 stdout 格式要求,而Claude是宽松( loose )的,这些差异被隔离在各自的模块中,不会互相影响。
  3. 易于维护和扩展 :未来如果支持新的AI助手平台(比如GitHub Copilot),开发者只需新增一个对应的目录(如 copilot/ ),实现其特有的模式,然后在根目录的 index.js 中适当导出即可,对现有代码影响最小。

实操心得:选择正确的导入路径 在实际编码时,我建议根据你的目标平台,直接导入对应的子模块,而不是根模块。虽然根模块 agent-hook-schemas 导出了所有功能,但直接使用子模块可以让你的意图更明确,也方便后续的依赖分析。

// 推荐:目标明确,只引入Claude相关
import { ParseHookInput } from 'agent-hook-schemas/claude';
import { mergeClaudeHooksFiles } from 'agent-hook-schemas/claude-hooks-integration';

// 也可以,但可能引入不必要的类型
// import { ParseHookInput } from 'agent-hook-schemas';

2.2 类型系统的核心:Zod Schema与判别式联合

这个库的强大,一半功劳要归于它底层对Zod的深度运用。Zod是一个以开发者体验著称的TypeScript模式验证库。 agent-hook-schemas 为每个平台的每一种钩子事件,都定义了一个Zod Schema。

例如,对于Claude Code的 PreToolUse 事件,库内部分定义可能类似于:

// 概念性代码,非实际源码
const PreToolUseSchema = z.object({
  hook_event_name: z.literal('PreToolUse'),
  session_id: z.string(),
  tool_name: z.string(),
  tool_input: z.record(z.unknown()), // 具体类型由工具解析器细化
  // ... 其他字段
});

ParseHookInput 这个函数的神奇之处在于,它内部使用了Zod的 z.discriminatedUnion 。它会根据传入JSON对象中的 hook_event_name 字段,自动选择对应的Schema进行验证和解析。解析成功后,TypeScript的类型系统就能确切地知道 result.data PreToolUse 类型、 SessionStart 类型还是其他,这就是 判别式联合 的威力。

const result = ParseHookInput(rawInput);
if (result.success) {
  // 此时,TypeScript知道data是26种Claude事件之一
  const data = result.data;

  // 通过判断hook_event_name,类型会被自动收窄
  if (data.hook_event_name === 'PreToolUse') {
    // 在这里,data的类型被收窄为PreToolUseEvent
    console.log(`工具 ${data.tool_name} 将被调用`);
    // 你可以安全地访问PreToolUse事件特有的属性
  } else if (data.hook_event_name === 'SessionStart') {
    // 在这里,data的类型被收窄为SessionStartEvent
    console.log(`新会话开始,模型是:${data.model}`);
  }
}

这种设计让你在编写钩子逻辑时,既能享受到联合类型的便利(一个函数处理所有事件),又能在处理特定事件时获得精确的类型提示和安全性。

2.3 配置合并与解析:从多层配置到可执行处理器

钩子配置的“层叠”是另一个复杂点。以Claude Code为例,它允许在三个地方定义钩子:

  1. 用户全局配置 ( ~/.config/claude/claude_desktop_config.json ): 适用于所有项目。
  2. 项目级配置 ( 项目根目录/.claude/hooks.json ): 适用于特定项目。
  3. 本地配置 (IDE工作区设置): 优先级最高。

当同一个事件(如 PreToolUse )在多层配置中都有定义时,Claude需要一套规则来合并它们。 agent-hook-schemas claude-hooks-integration 模块,就是这套规则的官方实现。

mergeClaudeHooksFiles 函数的核心逻辑是 优先级合并与重置 。它接受一个配置对象数组,按优先级从低到高传递(如 [用户配置, 项目配置] )。合并时,高优先级的配置会覆盖低优先级的同名事件配置。特别需要注意的是 disableAllHooks: true 这个开关,如果它在某一层被设置,它会 重置 所有更低优先级的钩子配置,这是一个需要小心处理的行为。

合并得到最终配置后,面对一个具体的钩子事件,如何知道要执行哪个脚本呢?这就是 resolveMatchingClaudeHandlersFromInput 函数的工作。它内部会做两件事:

  1. 主题解析 :根据事件类型和上下文,解析出 subject 。例如,对于 PreToolUse 事件且 tool_name Bash subject 就是 Bash
  2. 匹配器评估 :遍历该事件下所有配置的处理器,检查其 matcher 字段(一个正则表达式)是否匹配上一步解析出的 subject 。如果匹配,且可选的 if 守卫条件(如 Tool("rm *") )也通过,那么这个处理器就会被选中。

这个过程完全自动化,你只需要调用一个函数,就能得到当前事件下所有需要执行的处理器列表,每个处理器都包含了要运行的命令、HTTP端点或提示词。

3. 从零开始:实战开发一个跨平台安全钩子

理论说得再多,不如动手写一个。假设我们要实现一个简单的安全钩子: 禁止AI助手执行任何删除根目录( rm -rf / )或格式化磁盘( format )等危险命令 。我们希望这个钩子能同时用于Claude Code和Cursor。

3.1 环境搭建与项目初始化

首先,创建一个新的Node.js项目,并安装依赖。

mkdir ai-hook-guard && cd ai-hook-guard
npm init -y
npm install agent-hook-schemas zod
npm install -D typescript @types/node

由于 agent-hook-schemas typescript 作为 peerDependency ,我们需要在项目中自行安装TypeScript(v5+)。同时安装Node.js类型定义以便使用 process.stdin 等API。

接着,初始化TypeScript配置。

npx tsc --init

在生成的 tsconfig.json 中,确保以下设置被启用或添加:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

创建源代码目录和入口文件。

mkdir src
touch src/index.ts

3.2 编写核心钩子逻辑

我们的钩子脚本需要做以下几件事:

  1. 从标准输入( stdin )读取AI助手传递过来的JSON数据。
  2. 解析数据,判断事件类型。
  3. 如果是 PreToolUse preToolUse (对应Cursor)事件,并且工具是 Bash Shell ,则检查命令内容。
  4. 如果命令包含危险模式,则输出“拒绝”的响应;否则输出“允许”。
  5. 将响应JSON打印到标准输出( stdout )。

下面是 src/index.ts 的实现:

import { ParseHookInput } from 'agent-hook-schemas/claude';
import { ParseCursorHookInput } from 'agent-hook-schemas/cursor';
import { HookCommandOutputSchema } from 'agent-hook-schemas';

// 定义危险命令模式
const DANGEROUS_PATTERNS = [
  /rm\s+(-rf|-r\s+-f)\s+\/(\s+|$)/, // rm -rf / 或 rm -r -f /
  /format\s+\/dev\/sd[a-z]/,         // 格式化磁盘
  /dd\s+if=\/dev\/.*\s+of=\/dev\/sd[a-z]/, // 使用dd覆盖磁盘
  /chmod\s+[-+]?[0-7]{3,4}\s+\/.*/,  // 递归修改根目录权限
  /^shutdown\s+(-h\s+now|-P\s+now|now)/, // 立即关机
];

function isDangerousCommand(command: string): boolean {
  const trimmedCommand = command.trim();
  return DANGEROUS_PATTERNS.some(pattern => pattern.test(trimmedCommand));
}

async function main() {
  try {
    // 1. 读取stdin
    const stdinContent = await Bun.stdin.text(); // 使用Bun运行时。如用Node.js,需使用readFileSync(0)
    const rawInput = JSON.parse(stdinContent);

    let eventName: string;
    let toolName: string | undefined;
    let toolInput: any;

    // 2. 尝试解析为Claude Code事件
    const claudeResult = ParseHookInput(rawInput);
    if (claudeResult.success) {
      eventName = claudeResult.data.hook_event_name;
      if (eventName === 'PreToolUse') {
        toolName = claudeResult.data.tool_name;
        toolInput = claudeResult.data.tool_input;
      }
    } else {
      // 3. 如果不是Claude,尝试解析为Cursor事件
      const cursorResult = ParseCursorHookInput(rawInput);
      if (cursorResult.success) {
        eventName = cursorResult.data.hookEventName; // Cursor使用驼峰命名
        if (eventName === 'preToolUse') {
          toolName = cursorResult.data.toolName;
          toolInput = cursorResult.data.toolInput;
        }
      } else {
        // 无法识别的事件格式
        console.error(JSON.stringify({
          error: 'Unsupported hook event format',
          input: rawInput
        }));
        process.exit(1);
      }
    }

    // 4. 处理PreToolUse/preToolUse事件
    if (eventName === 'PreToolUse' || eventName === 'preToolUse') {
      if (toolName === 'Bash' || toolName === 'Shell') {
        const command = toolInput?.command;
        if (typeof command === 'string' && isDangerousCommand(command)) {
          // 5. 构建并输出拒绝响应
          const denyOutput = HookCommandOutputSchema.parse({
            hookSpecificOutput: {
              hookEventName: 'PreToolUse', // 注意:输出时使用PascalCase
              permissionDecision: 'deny',
              permissionDecisionReason: `安全策略阻止了危险命令: ${command}`,
            },
          });
          console.log(JSON.stringify(denyOutput));
          process.exit(0);
        }
      }
      // 6. 非危险命令或非Bash/Shell工具,允许执行
      const allowOutput = HookCommandOutputSchema.parse({
        hookSpecificOutput: {
          hookEventName: 'PreToolUse',
          permissionDecision: 'allow',
          permissionDecisionReason: '命令安全检查通过',
        },
      });
      console.log(JSON.stringify(allowOutput));
      process.exit(0);
    }

    // 7. 对于其他事件,默认允许继续
    const defaultOutput = HookCommandOutputSchema.parse({
      continue: true,
    });
    console.log(JSON.stringify(defaultOutput));

  } catch (error) {
    console.error(JSON.stringify({
      error: 'Hook script execution failed',
      message: error instanceof Error ? error.message : String(error)
    }));
    process.exit(1);
  }
}

main();

代码解析与注意事项:

  1. 运行时选择 :示例使用了 Bun.stdin.text() 。如果你使用Node.js,需要替换为 fs.readFileSync(0, 'utf-8') 来读取stdin。确保你的脚本运行环境支持相应的API。
  2. 平台探测 :我们依次尝试用Claude和Cursor的解析器来解析输入。这种顺序取决于你的主要使用场景。也可以根据输入中的某些特征字段(如是否存在 cursor_version )来智能判断。
  3. 事件名大小写 :注意Claude使用 PascalCase (如 PreToolUse ),而Cursor使用 camelCase (如 preToolUse )。在输出响应时, hookSpecificOutput.hookEventName 字段需要与输入平台期望的格式一致,示例中统一用了 PreToolUse ,对于Claude和Codex是兼容的,但严格来说,为Cursor写钩子可能需要适配其输出格式(尽管Cursor可能只读stdin,不依赖特定stdout)。
  4. 输出验证 :使用 HookCommandOutputSchema.parse() 而不是直接 JSON.stringify 一个对象,这能确保我们输出的JSON完全符合Claude Code的 stdout 格式要求,避免因细微格式错误导致钩子失效。
  5. 错误处理 :任何未捕获的异常都可能导致钩子脚本非正常退出,AI助手可能会因此行为异常。务必用 try-catch 包裹主逻辑,并以AI助手能理解的JSON格式输出错误信息。

3.3 编译、测试与配置

编写完脚本后,我们需要将其编译成JavaScript并配置到AI助手中。

首先,编译TypeScript:

npx tsc

这会在 dist 目录下生成 index.js

测试你的钩子 :在真实接入前,模拟测试至关重要。创建一个测试输入文件 test_input.json ,模拟Claude Code的 PreToolUse 事件:

{
  "hook_event_name": "PreToolUse",
  "session_id": "test-session-123",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/test"
  },
  "cwd": "/home/user/project",
  "permission_mode": "default"
}

然后运行你的钩子脚本:

node dist/index.js < test_input.json

观察输出是否符合预期(应该输出一个 permissionDecision: "allow" 的JSON,因为 /tmp/test 不在危险列表中)。再创建一个包含 rm -rf / 的测试输入,检查是否被正确拒绝。

配置Claude Code :在Claude Code的设置文件(例如项目级的 .claude/hooks.json )中添加钩子配置。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node /绝对路径/到/你的项目/dist/index.js",
            "timeoutSec": 5
          }
        ]
      }
    ]
  }
}

matcher: "Bash" 确保这个钩子只对 Bash 工具调用生效。 timeoutSec 设置了超时时间,防止脚本挂起导致AI助手卡住。

配置Cursor :Cursor的配置方式可能不同,通常在其设置UI中或工作区的 .cursor 配置文件中指定钩子脚本路径。你需要根据Cursor的文档,将编译好的脚本路径配置到相应的 preToolUse 事件处理器上。

4. 高级应用:构建一个配置驱动的动态钩子系统

上面的例子是静态的,规则写在代码里。但在企业级场景中,安全规则可能需要动态更新,或者针对不同项目、不同用户有不同策略。我们可以利用 agent-hook-schemas 的配置合并与解析能力,构建一个更强大的、配置驱动的钩子网关。

4.1 设计思路:中央策略服务

设想这样一个系统:我们不再把安全检查逻辑写在每个钩子脚本里,而是创建一个“策略中心”服务(可以是一个本地HTTP服务或一个简单的JSON文件)。钩子脚本本身变得非常轻薄,它只负责三件事:

  1. 解析AI助手传入的事件。
  2. 根据事件上下文(如会话ID、项目路径、工具名、命令)向“策略中心”查询决策。
  3. 将决策结果(允许/拒绝)返回给AI助手。

“策略中心”则可以是一个复杂的规则引擎,支持从数据库、配置文件或远程API加载规则。这样做的好处是:

  • 策略与执行解耦 :安全团队可以独立更新策略规则,无需重新部署或修改钩子脚本。
  • 集中审计 :所有AI助手的操作请求和决策结果都可以被集中记录和审计。
  • 支持复杂逻辑 :可以轻松实现基于用户角色、项目敏感度、时间、命令历史等多维度的策略。

4.2 实现轻量级钩子网关

首先,我们实现轻量级的钩子脚本 gateway.ts

import { ParseHookInput } from 'agent-hook-schemas/claude';
import { HookCommandOutputSchema } from 'agent-hook-schemas';
import * as fs from 'fs/promises';
import * as path from 'path';

// 策略中心的接口定义
interface PolicyDecision {
  decision: 'allow' | 'deny' | 'ask';
  reason: string;
  // 可附加更多信息,如需要人工审核时的通知渠道
}

// 模拟从本地策略文件查询决策
async function queryPolicy(
  event: string,
  toolName: string | undefined,
  toolInput: any,
  cwd: string,
  sessionId: string
): Promise<PolicyDecision> {
  // 1. 尝试加载项目级策略文件
  const projectPolicyPath = path.join(cwd, '.ai-policy.json');
  let policyRules = [];
  try {
    const content = await fs.readFile(projectPolicyPath, 'utf-8');
    policyRules = JSON.parse(content).rules || [];
  } catch {
    // 文件不存在或无效,使用空规则
  }

  // 2. 加载用户全局策略(示例路径)
  const globalPolicyPath = path.join(process.env.HOME || '', '.config', 'ai-policy.json');
  try {
    const content = await fs.readFile(globalPolicyPath, 'utf-8');
    const globalRules = JSON.parse(content).rules || [];
    // 全局策略与项目策略合并,项目策略优先级更高(后加载的覆盖先加载的)
    policyRules = [...globalRules, ...policyRules];
  } catch {
    // 忽略
  }

  // 3. 应用规则引擎(简化版:顺序匹配)
  for (const rule of policyRules) {
    if (rule.event !== event) continue;
    if (rule.tool && rule.tool !== toolName) continue;
    if (rule.condition) {
      // 这里可以嵌入一个简单的表达式求值器,例如匹配命令正则
      if (toolName === 'Bash' && toolInput?.command) {
        const regex = new RegExp(rule.condition.pattern);
        if (regex.test(toolInput.command)) {
          return { decision: rule.action, reason: rule.reason };
        }
      }
    } else {
      // 无条件规则,直接应用
      return { decision: rule.action, reason: rule.reason };
    }
  }

  // 4. 默认策略:允许
  return { decision: 'allow', reason: '符合默认策略' };
}

async function main() {
  try {
    const stdinContent = await Bun.stdin.text();
    const rawInput = JSON.parse(stdinContent);
    const result = ParseHookInput(rawInput);

    if (!result.success) {
      throw new Error(`Invalid hook input: ${result.error.message}`);
    }

    const input = result.data;
    const { hook_event_name, session_id, cwd } = input;
    const toolName = 'tool_name' in input ? input.tool_name : undefined;
    const toolInput = 'tool_input' in input ? input.tool_input : undefined;

    // 只处理PreToolUse事件
    if (hook_event_name !== 'PreToolUse') {
      const output = HookCommandOutputSchema.parse({ continue: true });
      console.log(JSON.stringify(output));
      return;
    }

    // 查询策略
    const policy = await queryPolicy(
      hook_event_name,
      toolName,
      toolInput,
      cwd,
      session_id
    );

    // 构建响应
    const output = HookCommandOutputSchema.parse({
      hookSpecificOutput: {
        hookEventName: 'PreToolUse',
        permissionDecision: policy.decision,
        permissionDecisionReason: policy.reason,
      },
    });
    console.log(JSON.stringify(output));

  } catch (error) {
    console.error(JSON.stringify({
      error: 'Policy gateway error',
      message: error instanceof Error ? error.message : String(error)
    }));
    process.exit(1);
  }
}

main();

4.3 定义策略规则文件

接下来,创建策略文件。项目级策略 .ai-policy.json

{
  "version": "1.0",
  "rules": [
    {
      "event": "PreToolUse",
      "tool": "Bash",
      "condition": {
        "type": "regex",
        "pattern": "^rm\\s+-rf\\s+\\/"
      },
      "action": "deny",
      "reason": "禁止删除根目录"
    },
    {
      "event": "PreToolUse",
      "tool": "Bash",
      "condition": {
        "type": "regex",
        "pattern": "curl\\s+-X\\s+POST.*(password|token|key)"
      },
      "action": "ask",
      "reason": "检测到可能包含敏感信息的网络请求,需要人工确认"
    }
  ]
}

用户全局策略 ~/.config/ai-policy.json

{
  "version": "1.0",
  "rules": [
    {
      "event": "PreToolUse",
      "tool": "Edit",
      "condition": {
        "type": "path",
        "pattern": "\\.(env|secret|key)$"
      },
      "action": "deny",
      "reason": "禁止编辑敏感的环境变量或密钥文件"
    }
  ]
}

系统工作流程

  1. AI助手(如Claude Code)触发 PreToolUse 事件。
  2. 钩子脚本 gateway.js 被调用,读取事件数据。
  3. 脚本首先查找项目根目录下的 .ai-policy.json ,然后查找用户全局策略。
  4. 按顺序评估规则:如果事件、工具名匹配,并且条件(如命令正则匹配)满足,则采用该规则的决策。
  5. 脚本将决策( allow / deny / ask )及原因返回给AI助手。
  6. AI助手根据决策决定是执行、拒绝还是向用户发起询问。

这个架构的扩展性很强。你可以轻松地将 queryPolicy 函数替换为调用一个远程HTTP API,实现真正的中央策略管理;也可以在规则条件中引入更复杂的逻辑,比如检查命令是否在“学习期”内已执行过多次,或者结合项目的 git 历史来判断风险。

4.4 利用集成模块优化配置管理

在上面的网关示例中,我们手动实现了简单的策略合并。但实际上,对于Claude Code,我们可以直接使用 agent-hook-schemas/claude-hooks-integration 模块来管理更复杂的、符合Claude原生语法的钩子配置。

假设我们有一个复杂的项目级钩子配置,它混合了直接命令和HTTP钩子,并且有精细的匹配器:

// .claude/hooks.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Tool(\"git push origin main\")",
        "hooks": [
          {
            "type": "http",
            "url": "http://localhost:8080/hooks/confirm-push",
            "timeoutSec": 10
          }
        ]
      },
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node /path/to/linter.js",
            "timeoutSec": 30
          }
        ]
      }
    ]
  }
}

同时,用户可能在全局配置中禁用了所有钩子,或在另一个本地配置中增加了新的规则。手动合并这些配置并解析出针对一次具体的 Edit 工具调用应该执行哪个钩子,是非常容易出错的。

此时,使用库提供的工具就非常简单:

import { mergeClaudeHooksFiles, resolveMatchingClaudeHandlersFromInput } from 'agent-hook-schemas/claude-hooks-integration';
import { ParseHookInput } from 'agent-hook-schemas/claude';

// 假设我们加载了多层配置
const userConfig = loadConfig('~/.config/claude/claude_desktop_config.json');
const projectConfig = loadConfig('./.claude/hooks.json');
const localConfig = loadConfigFromWorkspace(); // 从IDE获取

// 1. 合并配置
const mergeResult = mergeClaudeHooksFiles([userConfig, projectConfig, localConfig]);
if (!mergeResult.ok) {
  throw new Error(`配置合并失败: ${mergeResult.error}`);
}
const finalConfig = mergeResult.config;

// 2. 当收到一个钩子事件时
const rawInput = await readStdin();
const parseResult = ParseHookInput(rawInput);
if (!parseResult.success) { /* 处理错误 */ }
const input = parseResult.data;

// 3. 解析出应该执行的所有处理器
const handlers = resolveMatchingClaudeHandlersFromInput(finalConfig, input);

for (const handler of handlers) {
  if (handler.type === 'command') {
    console.log(`需要执行命令: ${handler.command}`);
    // 在这里可以派生子进程执行命令,并收集结果
  } else if (handler.type === 'http') {
    console.log(`需要调用HTTP端点: ${handler.url}`);
    // 在这里可以发起HTTP请求
  }
  // 根据handler.timeoutSec处理超时
}

resolveMatchingClaudeHandlersFromInput 函数内部已经处理了 matcher 的正则匹配和 if 守卫条件的评估(如 Tool("git push origin main") ),你得到的就是一个已经过滤好的、待执行的处理器列表。这让你能专注于执行业务逻辑,而不是复杂的配置解析。

5. 深入原理:解析器、合并策略与平台差异

要真正玩转 agent-hook-schemas ,避免踩坑,必须理解其内部的一些设计原理和不同平台间的关键差异。

5.1 Zod宽松模式( .loose() )的考量

你可能在文档中注意到,所有平台的 stdin 解析都标注为“Loose ( .loose() )”。这是Zod Schema的一个模式。在严格模式下,如果传入的对象包含Schema未定义的字段,验证会失败。而在宽松模式下,这些额外的字段会被忽略,但不会导致验证失败。

AI助手钩子的 stdin 格式可能会随着版本更新而增加新字段。使用宽松模式可以确保你的钩子脚本在AI助手升级后,即使收到了一些未知的新字段,也不会立即崩溃,保持了 向后兼容性 。但这也意味着,你的代码不能依赖那些“额外”的字段,因为它们可能在某些版本或某些平台上不存在。

实操建议 :在编写钩子逻辑时,只使用库的TypeScript类型定义中明确声明的字段。如果你发现某个平台提供了一个有用的新字段(比如 timestamp ),但库的类型定义尚未更新,可以提交Issue或PR给库作者,而不是直接去 rawInput 里取用,因为未来库更新后,你的代码可能会因为字段名或类型变化而中断。

5.2 配置合并的优先级与“重置”语义

mergeClaudeHooksFiles 的合并逻辑并非简单的“后者覆盖前者”。理解其优先级规则对调试配置冲突至关重要。

假设有以下配置:

  • 用户层 :定义了 PreToolUse 的钩子A。
  • 项目层 :定义了 PreToolUse 的钩子B,并且设置了 disableAllHooks: true
  • 本地层 :定义了 PreToolUse 的钩子C。

合并过程是:

  1. 从低优先级(用户层)开始。
  2. 遇到项目层的 disableAllHooks: true ,这会 清空 当前累积的所有钩子配置(即钩子A被丢弃)。
  3. 然后继续合并项目层自己的配置(钩子B被加入)。
  4. 最后合并高优先级的本地层配置(钩子C被加入,附加在钩子B之后)。

所以最终生效的 PreToolUse 钩子是 [B, C] ,用户层的A被完全忽略了。这是一个常见的坑点:项目维护者可能无意中用一个 disableAllHooks: true 的设置,覆盖了所有用户自定义的全局安全钩子。

排查技巧 :当你的钩子不按预期运行时,使用库提供的 parseClaudeSettings 等验证函数,分别检查每一层配置是否有效。然后手动调用 mergeClaudeHooksFiles ,并打印出合并后的结果,查看最终生效的配置到底是什么。

5.3 平台差异详解与适配策略

agent-hook-schemas 的对比表格非常详细,这里针对几个关键差异点展开说明,并给出适配建议。

1. 事件命名与数量差异 Claude Code有26个事件,最为丰富,涵盖了从会话生命周期、工具调用到文件变更、任务管理的方方面面。Codex只有5个核心事件。Gemini CLI有11个。Cursor有20个,但命名采用 camelCase

适配策略 :如果你的钩子逻辑依赖于某个特定事件(如 FileChanged ),那么这个钩子就无法移植到不支持该事件的平台(如Codex)。在设计跨平台钩子时,应围绕几个最通用的事件展开: SessionStart PreToolUse / BeforeTool PostToolUse / AfterTool Stop

2. Stdout格式严格性 Codex要求 stdout 输出必须严格符合其定义的Schema(使用 .strict() 模式),并且某些字段有默认值。例如, continue 字段默认为 true ,但如果你不输出它,验证可能会失败。而Claude和Gemini则相对宽松。

适配策略 :为Codex编写钩子时,务必使用从 agent-hook-schemas/codex 导出的专用输出Schema(如 CodexPreToolUseOutputSchema )来构建响应,而不是使用通用的 HookCommandOutputSchema 。这能确保生成的JSON完全符合Codex的预期。

3. 权限决策(Permission Decision)的语义 PreToolUse 事件中,Claude和Codex都支持 allow deny ask 。但Claude多了一个 defer (推迟),含义是将决定权交给下一个钩子或默认权限系统。此外,Claude有独立的 PermissionRequest PermissionDenied 事件来处理更复杂的权限交互流。

适配策略 :如果你的钩子目的是做最终的安全拦截,使用 deny 。如果是做审计或提示,使用 allow 并可能附加 systemMessage 来告知用户。谨慎使用 ask ,因为它会中断AI的工作流,弹出用户确认框。理解 defer 的语义,避免在责任链中产生循环依赖。

4. 工具输入解析的深度 对于 tool_input ,库提供了如 ParseBashToolInput ParseEditToolInput 这样的专用解析器。它们能将通用的 Record<string, unknown> 解析为具有明确类型的对象(如 { command: string } { file_path: string, old_content: string, new_content: string } )。

实操建议 :在处理 PreToolUse 事件时,一旦确定 tool_name ,就立即使用对应的工具输入解析器。这比直接操作 tool_input 对象安全得多,能避免类型错误,并且能获得IDE的自动补全。

if (input.hook_event_name === 'PreToolUse') {
  if (input.tool_name === 'Bash') {
    const bashInput = ParseBashToolInput(input.tool_input);
    if (bashInput.success) {
      // 现在 bashInput.data.command 是 string 类型
      analyzeCommand(bashInput.data.command);
    }
  } else if (input.tool_name === 'Edit') {
    const editInput = ParseEditToolInput(input.tool_input);
    if (editInput.success) {
      // 现在可以安全访问 file_path, old_content 等
      reviewEdit(editInput.data.file_path, editInput.data.new_content);
    }
  }
}

6. 性能优化、调试与故障排查

在生产环境中运行钩子,尤其是那些涉及网络请求或复杂计算的钩子,需要关注性能和稳定性。

6.1 超时控制与异步处理

钩子脚本必须在规定时间内响应,否则AI助手会认为钩子失败并可能采取默认行为(如直接放行)。超时时间由钩子配置中的 timeoutSec timeout 字段控制。

关键设置

  • Claude Code/Codex :默认 600秒 。这非常宽松,但你的脚本应该远快于此。
  • Gemini CLI :默认 60000毫秒 (60秒)。
  • 最佳实践 :在配置中为你的钩子设置一个合理的超时(如 5-30秒 )。在脚本内部,对于可能耗时的操作(如网络请求、文件扫描),要自己实现超时逻辑。
// 在钩子脚本中实现带超时的HTTP请求
import { setTimeout } from 'node:timers/promises';

async function callPolicyServerWithTimeout(url: string, data: any, timeoutMs: number) {
  const controller = new AbortController();
  const timeoutId = setTimeout(timeoutMs).then(() => {
    controller.abort();
    throw new Error(`Policy server request timeout after ${timeoutMs}ms`);
  });

  try {
    const response = await Promise.race([
      fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
        signal: controller.signal,
      }),
      timeoutId,
    ]);
    return await response.json();
  } finally {
    clearTimeout(timeoutId); // 清理定时器
  }
}

6.2 日志记录与调试

钩子脚本运行在AI助手的子进程中,其 stdout 被AI助手消费, stderr 通常输出到宿主机的标准错误流。建立有效的日志机制至关重要。

推荐做法

  1. 结构化日志 :使用 console.error 输出JSON格式的日志到 stderr ,方便用 jq 等工具解析。
    console.error(JSON.stringify({
      level: 'INFO',
      timestamp: new Date().toISOString(),
      sessionId: input.session_id,
      event: input.hook_event_name,
      message: '开始处理PreToolUse事件',
      tool: input.tool_name,
    }));
    
  2. 日志级别 :实现 DEBUG INFO WARN ERROR 等级别,通过环境变量控制输出粒度。
  3. 持久化 :将重要的决策日志(尤其是 deny 决策)写入文件或发送到日志聚合服务(如Loki、ELK),便于事后审计和统计分析。
  4. 调试模式 :开发时,可以暂时让钩子脚本将本应输出到 stdout 的决策也打印到 stderr ,或者写入一个临时文件,以便观察其逻辑是否正确,而不影响AI助手运行。

6.3 常见问题与排查清单

问题1:钩子脚本被调用,但AI助手似乎忽略了其输出。

  • 检查 stdout 格式 :确保输出的JSON完全符合对应平台要求的Schema。使用库提供的 *Schema.parse() safeParse() 方法进行验证。一个常见的错误是字段名拼写错误或类型不对(例如 reason 应该是 string 而不是 null )。
  • 检查退出码 :钩子脚本必须以退出码 0 结束。任何未捕获的异常都会导致非零退出码,这可能被AI助手解释为钩子执行失败。
  • 检查超时 :脚本是否在配置的超时时间内完成?如果超时,AI助手可能会使用默认行为。

问题2:配置了多个钩子,但只有部分生效。

  • 检查配置合并 :使用 mergeClaudeHooksFiles 等函数打印出最终合并后的配置,确认你期望的钩子确实在列表中,且 matcher if 条件设置正确。
  • 检查 disableAllHooks :查看更高优先级的配置(如项目级)是否设置了 disableAllHooks: true ,这会导致低优先级钩子全部失效。
  • 检查事件名和匹配器 :确认钩子配置的 event 名称与AI助手触发的事件完全一致(注意大小写)。确认 matcher 的正则表达式是否能匹配到事件的 subject

问题3:钩子脚本在特定命令或情况下性能很差。

  • 分析耗时操作 :在脚本中记录关键步骤的时间戳,找出瓶颈。是否是网络请求?是否是解析大文件?
  • 实现缓存 :对于重复的、计算成本高的决策(例如,针对同一个项目路径的规则加载),可以考虑在内存中进行短期缓存。注意缓存的有效期和内存管理。
  • 优化规则匹配 :如果规则很多,线性匹配效率低。可以考虑将规则按 event tool 预先分组,或对正则表达式进行编译和缓存。

问题4:TypeScript类型报错,但运行时似乎正常。

  • 确保TypeScript版本 agent-hook-schemas 要求TypeScript v5+,旧版本可能无法正确处理某些高级类型(如判别式联合)。
  • 检查导入路径 :确认你从正确的子路径导入。例如,处理Codex事件时,应使用 agent-hook-schemas/codex 导出的类型和解析器。
  • 更新包版本 :AI助手平台可能会更新其API,库也会随之更新。确保你使用的 agent-hook-schemas 版本与你的AI助手客户端版本兼容。

开发AI助手钩子是一个需要细致观察和不断调试的过程。充分利用 agent-hook-schemas 提供的类型安全和工具函数,结合系统的日志和测试,能够帮你构建出既强大又稳定的自动化助手管控层。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐