1. 项目概述:为AI编程助手构建统一的Hook处理框架

如果你正在为Claude Code、Cursor或者Codex这类AI编程助手编写自定义Hook脚本,那你一定遇到过这样的场景:每个平台发来的JSON数据结构都不一样,字段名、事件类型、甚至布尔值的表示方式都可能有细微差别。你不得不为每个平台写一套解析逻辑,小心翼翼地处理类型转换,还得时刻提防着配置文件的合并规则。这活儿干起来不仅重复,还容易出错,尤其是在需要跨平台维护一套钩子逻辑的时候。

agent-hook-schemas 这个npm包就是为了解决这个痛点而生的。它本质上是一个基于Zod的、类型安全的“翻译器”和“协调员”。它把Claude Code、OpenAI Codex、Google Gemini CLI以及Cursor这几个主流AI编程助手的Hook输入输出(stdin/stdout)协议,以及它们的配置文件格式,统一抽象成了一组TypeScript类型和验证器。这意味着,你可以用一套几乎相同的代码,去处理来自不同AI助手的Hook事件,而不用再关心底层协议的差异。

这个包的核心价值在于“标准化”和“安全性”。通过Zod Schema,它确保了从不可信的stdin JSON到完全类型安全的TypeScript对象之间的转换是可靠且无痛的。同时,它提供的配置合并与解析工具,让你能清晰地管理用户级、项目级、本地级等多层Hook配置,确保最终生效的规则符合预期优先级。无论是想拦截一个危险的 rm -rf 命令,还是在代码生成后自动运行linter,或是根据项目上下文动态修改AI的提示词, agent-hook-schemas 都能为你提供坚实、类型安全的基础设施。

2. 核心设计思路与架构解析

2.1 为什么选择Zod作为基石

在TypeScript生态中,运行时类型校验库有好几个选择,比如 io-ts yup ajv 等。 agent-hook-schemas 选择Zod,是经过深思熟虑的。首先,Zod的API设计非常符合TypeScript开发者的直觉,它的链式调用(如 .optional() .nullable() )写起来很流畅。更重要的是,Zod能直接从Schema推断出极其精确的TypeScript类型,这种“Schema即类型”的特性,让开发体验达到了无缝衔接。你定义一个Zod Schema,就同时拥有了运行时校验器和编译时类型,无需手动维护两套定义。

其次,Zod的性能和包体积在同类库中表现均衡,既保证了校验速度,又不会显著增加最终构建产物的体积。这对于一个可能被众多Hook脚本引用的基础库来说很重要。最后,Zod的社区活跃度和生态完善度很高,有丰富的第三方集成和工具链支持,这为 agent-hook-schemas 未来的扩展(比如支持更多AI助手)减少了阻力。

注意 agent-hook-schemas 要求Zod版本在v4以上,TypeScript版本在v5以上。这是因为包内部大量使用了Zod v4的高级特性(如 .pipe() 、更完善的联合类型推断)和TS 5.0的 const 类型参数等特性,以确保类型推断的精确性。如果你的项目还在用旧版本,升级是必要的。

2.2 多平台支持的架构策略

面对四个设计各异的AI助手平台, agent-hook-schemas 没有采用一个“大一统”的超级Schema来强行兼容所有差异,而是采用了更务实、更清晰的“子路径导出”(Subpath Exports)架构。

根模块(Barrel Export) import from "agent-hook-schemas" 会导出一个包含Claude Code、Codex、Cursor、Gemini以及所有集成模块的混合体。这适合快速上手或编写通用工具。但为了获得最佳的类型安全和明确的平台依赖,官方推荐使用子路径导入。

平台专属模块 :每个平台都有自己独立的入口点,例如:

  • agent-hook-schemas/claude : Claude Code的所有Schema和工具。
  • agent-hook-schemas/codex : OpenAI Codex的所有Schema和工具。
  • agent-hook-schemas/gemini : Gemini CLI的所有Schema和工具。
  • agent-hook-schemas/cursor : Cursor的所有Schema。

这种设计的好处显而易见。首先,它做到了 关注点分离 。当你只为Claude Code写Hook时,你只导入Claude相关的类型,不会不小心用到Codex特有的字段,代码意图更清晰,Tree-shaking也更高效。其次,它 保留了平台特性 。每个平台的Schema都忠实地反映了其官方协议,包括那些平台独有的字段(如Cursor的 cursor_version 、Claude的 watchPaths ),不会为了统一而牺牲信息。最后,它提供了 渐进式复杂度 。新手可以从根模块开始,快速验证想法;而在构建生产级Hook时,切换到平台专属模块能获得更严格的类型约束和更准确的API提示。

集成模块(Integration Modules) :这是架构中另一个精妙的设计。对于支持复杂配置的Claude、Codex和Gemini, agent-hook-schemas 额外提供了 -integration 后缀的模块(如 claude-hooks-integration )。这些模块不包含基础的协议Schema,而是专注于“业务逻辑”:如何合并多层配置、如何根据当前事件和上下文解析出应该运行的Handler、如何计算有效的超时时间等。将“协议描述”和“业务逻辑”分离,使得库的结构更清晰,也方便未来单独更新某一部分。

2.3 宽松(Loose)与严格(Strict)模式解析

在包的文档和代码中,你会频繁看到 .loose() .strict() 的调用。这不是性能上的宽松或严格,而是针对JSON数据校验的两种策略。

宽松模式(.loose()) :这是所有平台处理stdin输入的默认方式。在宽松模式下,Schema会使用 .passthrough() 或类似方法,允许并保留JSON中那些未在Schema中定义的额外字段。这是至关重要的,因为AI助手的协议可能会在未来版本中添加新字段。如果使用严格模式,未知字段会导致校验失败,你的Hook脚本可能会在新版本助手发布时突然崩溃。宽松模式确保了 向前兼容性 ,你的Hook可以安全地忽略它不关心的新字段,同时继续处理它认识的核心字段。

严格模式(.strict()) :主要用于 输出(stdout) ,特别是对于Codex平台。Codex对Hook脚本的输出格式要求非常精确,多一个或少一个字段,或者字段值为 undefined ,都可能导致通信失败。严格模式会剔除任何未在Schema中声明的字段,并将可选字段的 undefined 值转换为JSON null 或直接省略(取决于配置),确保输出的JSON完全符合Codex的预期格式。对于Claude和Gemini,它们的输出协议相对宽松,所以通常也使用宽松模式。

实操心得 :理解这两种模式是正确使用该库的关键。一个常见的错误是在解析输入时误用严格模式,导致升级AI助手后Hook失效。记住这个口诀: 输入用宽松(保兼容),输出看平台(Codex要严格) 。库的API设计已经帮你做了默认选择,例如 ParseHookInput 内部用的就是宽松模式,你一般不需要操心。

3. 从零开始:Hook脚本的完整开发流程

3.1 环境准备与项目初始化

假设我们要为一个Node.js项目创建一个Claude Code的 PreToolUse Hook,用于检查Bash命令的安全性。

首先,初始化项目并安装依赖:

# 创建一个新的Hook项目目录
mkdir my-security-hook && cd my-security-hook

# 初始化npm项目(或用你喜欢的包管理器)
npm init -y

# 安装 agent-hook-schemas 及其必需的peer dependency
npm install agent-hook-schemas zod

# 安装TypeScript和类型定义(如果你用TS)
npm install --save-dev typescript @types/node

# 初始化tsconfig.json
npx tsc --init

编辑 tsconfig.json ,确保设置适合脚本开发,比如 "target": "ES2022" , "module": "NodeNext" , 以及 "outDir": "./dist"

接下来,创建我们的Hook入口文件,例如 src/index.ts 。我们将从平台专属模块导入,以获得最精确的类型支持。

3.2 解析Hook输入:安全第一道防线

Hook脚本是一个独立的可执行程序,AI助手会通过stdin向它发送一个JSON对象。第一步,也是最重要的一步,就是安全地解析这个输入。

// src/index.ts
import { ParseHookInput } from 'agent-hook-schemas/claude';
// 注意:这里从`/claude`子路径导入,而非根路径。

async function main() {
  // 1. 读取stdin。使用Bun、Node.js或其他运行时的API。
  // 这里以Node.js为例:
  const stdinData = await readStdin();
  let rawInput: unknown;
  try {
    rawInput = JSON.parse(stdinData);
  } catch (error) {
    // 如果连JSON都不是,直接报错退出
    console.error(JSON.stringify({
      decision: 'block',
      reason: `Failed to parse stdin as JSON: ${error.message}`
    }));
    process.exit(1);
  }

  // 2. 使用库提供的解析函数进行校验和类型窄化
  const parseResult = ParseHookInput(rawInput);

  if (!parseResult.success) {
    // 校验失败!输入数据不符合Claude Code的协议。
    // 将详细的Zod错误信息输出,方便调试。
    console.error(JSON.stringify({
      decision: 'block',
      reason: `Invalid hook input: ${JSON.stringify(parseResult.error.format())}`
    }));
    process.exit(1);
  }

  // 3. 至此,input已被成功解析,并且TypeScript知道它的具体类型
  const input = parseResult.data;
  // 例如,input.hook_event_name 现在是如 "PreToolUse" 这样的字面量类型
  // input.session_id, input.cwd 等字段都有明确的类型

  // ... 后续处理逻辑
}

// Node.js下读取stdin的辅助函数
function readStdin(): Promise<string> {
  return new Promise((resolve) => {
    let data = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('readable', () => {
      let chunk;
      while ((chunk = process.stdin.read()) !== null) {
        data += chunk;
      }
    });
    process.stdin.on('end', () => {
      resolve(data);
    });
  });
}

main().catch((error) => {
  console.error(JSON.stringify({ decision: 'block', reason: `Unhandled error: ${error.message}` }));
  process.exit(1);
});

关键点解析

  1. 错误处理 :在解析JSON和校验Schema阶段就做好错误处理至关重要。如果输入格式错误,必须通过stdout返回一个合法的拒绝响应( decision: 'block' )并退出,而不是抛出未捕获的异常,否则AI助手可能无法正确处理Hook的失败。
  2. 类型安全 ParseHookInput 的返回值类型是 Zod.SafeParseReturnType<...> 。通过检查 success 属性,TypeScript能够进行**可辨识联合(Discriminated Union)**类型收窄。在 if (parseResult.success) 分支内, parseResult.data 就是完全类型安全的Hook输入对象。这是Zod的核心优势。
  3. 平台特异性 :我们导入了 agent-hook-schemas/claude 中的 ParseHookInput 。如果我们要支持Codex,则需要从 agent-hook-schemas/codex 导入 ParseCodexHookInput 。函数名和类型都是平台定制的。

3.3 实现核心逻辑:命令安全检查

假设我们的Hook只在 PreToolUse 事件且工具名为 Bash 时触发,检查命令是否包含危险模式。

// 接上面的 main 函数
if (input.hook_event_name === 'PreToolUse') {
  // 进一步检查工具类型
  if (input.tool_name === 'Bash') {
    // 使用库提供的工具输入解析器,获取结构化的命令信息
    const bashParseResult = ParseBashToolInput(input.tool_input);
    
    if (!bashParseResult.success) {
      // 理论上,如果ParseHookInput成功了,这里也应该成功。
      // 但为了健壮性,仍做处理。
      console.log(JSON.stringify({
        hookSpecificOutput: {
          hookEventName: 'PreToolUse',
          permissionDecision: 'deny' as const,
          permissionDecisionReason: `无法解析Bash工具输入`,
        },
      }));
      process.exit(0);
    }

    const bashInput = bashParseResult.data;
    const command = bashInput.command;
    
    // 定义危险命令模式
    const dangerousPatterns = [
      /rm\s+(-rf|--recursive\s+--force)\s+\//, // 递归强制删除根目录
      /^dd\s+if=.*\s+of=\/dev\/sd[a-z]/, // 磁盘擦写
      /chmod\s+[0-7]{3,4}\s+\/etc\/passwd/, // 修改关键系统文件权限
      /mv\s+.*\s+\/dev\/null/, // 移动文件到黑洞
      /^:\s*{\s*\|:&\s*};/, // Fork炸弹简化模式
    ];

    const isDangerous = dangerousPatterns.some(pattern => pattern.test(command));
    
    if (isDangerous) {
      // 拒绝执行
      const denyOutput = HookCommandOutputSchema.parse({
        hookSpecificOutput: {
          hookEventName: 'PreToolUse',
          permissionDecision: 'deny',
          permissionDecisionReason: `命令被安全策略阻止: ${command.substring(0, 100)}...`,
        },
      });
      console.log(JSON.stringify(denyOutput));
    } else {
      // 允许执行
      const allowOutput = HookCommandOutputSchema.parse({
        hookSpecificOutput: {
          hookEventName: 'PreToolUse',
          permissionDecision: 'allow',
          permissionDecisionReason: `命令安全检查通过`,
        },
      });
      console.log(JSON.stringify(allowOutput));
    }
  } else {
    // 对于非Bash工具,我们选择“询问”(ask)或“默认”(default)决策。
    // 这里使用`defer`,表示不干预,交由Claude Code的默认权限逻辑处理。
    const deferOutput = HookCommandOutputSchema.parse({
      hookSpecificOutput: {
        hookEventName: 'PreToolUse',
        permissionDecision: 'defer',
        permissionDecisionReason: `非Bash工具,交由系统处理`,
      },
    });
    console.log(JSON.stringify(deferOutput));
  }
  process.exit(0); // 处理完毕,退出脚本
}

// 如果不是PreToolUse事件,直接退出,不输出任何内容(表示不干预)
process.exit(0);

注意事项

  • 权限决策(permissionDecision) :Claude Code支持 allow deny ask defer deny 直接阻止; ask 会弹出对话框询问用户; defer 表示Hook不做出决定,由系统或更低优先级的Hook处理。合理使用 defer 可以让你编写的Hook只关注特定场景。
  • 输出验证 :使用 HookCommandOutputSchema.parse() .safeParse() 来构建输出对象。这确保了输出的JSON格式完全符合Claude Code的要求,即使是可选字段(如 reason )也会被正确处理。直接手动拼接JSON字符串很容易出错。
  • 工具输入解析 ParseBashToolInput 是一个平台提供的便捷函数。对于 Bash 工具, tool_input 可能是一个简单的 { command: string } 对象,但也可能包含 cwd env 等字段。使用这个解析器比直接访问 input.tool_input.command 更安全,因为它处理了类型转换和可能的字段缺失情况。

3.4 构建、测试与部署

编写完脚本后,需要将其编译(如果是TypeScript)并设置为可执行文件。

# 编译TypeScript
npx tsc

# 或者使用更快的esbuild/tsup,这里以tsup为例(需安装)
# npx tsup src/index.ts --format cjs --platform node --out-dir dist

假设编译后的文件是 dist/index.js 。使其可执行,并在Claude Code中配置Hook。

首先,创建一个Hook配置文件,例如在项目根目录的 .claude/hooks.json (用户级配置在 ~/.claude/desktop-config.json 中)。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node /absolute/path/to/your/project/dist/index.js",
            "timeoutSec": 5
          }
        ]
      }
    ]
  }
}

关键配置解释

  • matcher: "Bash" :表示这个Hook组只匹配工具名为 Bash PreToolUse 事件。
  • type: "command" :表示执行一个本地命令。
  • timeoutSec :非常重要!设置一个合理的超时时间(如5秒)。如果Hook脚本挂起或执行过慢,超时后会被终止,AI助手会按超时行为处理(通常是继续执行)。超时时间不宜过长,以免阻塞用户操作。

测试你的Hook

  1. 在终端启动Claude Code。
  2. 在支持的编辑器中,尝试让Claude执行一个危险命令,如 rm -rf /tmp/somefile (先在安全环境测试!)。
  3. 观察Claude的响应。如果Hook工作正常,你应该会看到操作被阻止,并且可能看到你提供的 permissionDecisionReason

调试技巧 :在开发初期,可以在Hook脚本中把解析后的 input 对象和你的决策逻辑打印到 stderr console.error )。Claude Code通常会在其日志中捕获这些输出,方便你排查问题。但生产环境中应避免输出过多调试信息。

4. 高级应用:多层配置管理与动态解析

对于团队或复杂项目,Hook配置往往不是单一的。Claude Code、Codex和Gemini都支持配置的层级覆盖(通常顺序是:项目本地配置 > 项目配置 > 用户全局配置 > 系统默认)。 agent-hook-schemas 的集成模块提供了强大的工具来管理这种复杂性。

4.1 合并多层配置

假设我们有一个项目,在项目级配置中定义了一个通用的代码风格检查Hook,而某个开发者想在自己的本地配置中针对特定目录禁用这个Hook,或者添加一个额外的安全检查。

// config-manager.ts
import { mergeClaudeHooksFiles, parseClaudeSettings } from 'agent-hook-schemas/claude-hooks-integration';
import fs from 'fs/promises';
import path from 'path';

async function loadEffectiveConfig(projectRoot: string) {
  // 1. 定义配置加载顺序(从低优先级到高优先级)
  const configPaths = [
    // 系统默认(假设没有,或我们提供一个基础配置)
    { source: 'default', config: { hooks: {} } },
    // 用户全局配置
    path.join(process.env.HOME || '~', '.claude/desktop-config.json'),
    // 项目级配置
    path.join(projectRoot, '.claude/hooks.json'),
    // 项目本地配置(通常.gitignore)
    path.join(projectRoot, '.claude/hooks.local.json'),
  ];

  const configLayers = [];
  
  for (const configPath of configPaths) {
    if (typeof configPath === 'string') {
      try {
        const data = await fs.readFile(configPath, 'utf-8');
        const json = JSON.parse(data);
        // 使用库的解析器验证单个配置文件
        const parsed = parseClaudeSettings(json);
        if (parsed.ok) {
          configLayers.push(parsed.settings);
        } else {
          console.warn(`Invalid config at ${configPath}:`, parsed.error);
        }
      } catch (error) {
        // 文件不存在是预期内的,忽略;其他错误告警
        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
          console.warn(`Failed to load config from ${configPath}:`, error.message);
        }
      }
    } else {
      // 传入的默认配置对象
      configLayers.push(configPath.config);
    }
  }

  // 2. 合并所有有效的配置层
  const mergeResult = mergeClaudeHooksFiles(configLayers);
  
  if (!mergeResult.ok) {
    throw new Error(`Failed to merge configs: ${mergeResult.error.message}`);
  }

  return mergeResult.config; // 这就是最终生效的Hook配置
}

mergeClaudeHooksFiles 函数的核心作用不仅仅是简单的对象合并。它遵循Claude Code官方的合并语义:

  • 对于 hooks 下的每个事件(如 PreToolUse ),配置是**追加(append)**的。这意味着项目配置的 PreToolUse Hook会排在用户配置的后面执行(取决于具体实现,有时顺序可能反过来,需查证文档,但合并逻辑会处理)。
  • 特殊的 disableAllHooks: true 设置具有**重置(reset)**效果。如果某一层配置设置了此选项,它会清空所有更低优先级的Hook配置,然后将自己(可能为空)的配置作为起点。这在需要完全禁用上级配置的场景下非常有用。

4.2 运行时解析匹配的Handler

有了合并后的配置,当Hook事件发生时,我们需要知道具体要执行哪些命令或HTTP请求。这就是 resolveMatchingClaudeHandlersFromInput 的用武之地。

// 在Hook脚本中,或在一个中心化的Hook调度器中
import { resolveMatchingClaudeHandlersFromInput } from 'agent-hook-schemas/claude-hooks-integration';

// 假设我们已经有了合并后的配置 `effectiveConfig` 和解析好的输入 `input`
const matchedHandlers = resolveMatchingClaudeHandlersFromInput(effectiveConfig, input);

console.log(`将为事件 ${input.hook_event_name} 执行 ${matchedHandlers.length} 个处理器。`);

for (const handler of matchedHandlers) {
  switch (handler.type) {
    case 'command':
      console.log(`  - 执行命令: ${handler.command}`);
      // 这里可以调用 child_process.spawn 来执行命令
      // 注意处理超时(handler.timeoutSec)和输入输出
      break;
    case 'http':
      console.log(`  - 发送HTTP请求到: ${handler.url}`);
      // 使用fetch或axios发送请求
      break;
    case 'prompt':
      console.log(`  - 使用提示词模板: ${handler.prompt}`);
      // 通常用于动态修改系统提示词,逻辑更复杂
      break;
    case 'agent':
      console.log(`  - 调用子Agent: ${handler.agent}`);
      // 调用另一个AI Agent进行处理
      break;
  }
}

这个解析过程考虑了:

  1. 事件匹配 :只选择配置中针对当前 hook_event_name 的处理器组。
  2. Matcher匹配 :每个处理器组可以有一个 matcher 字段(通常是正则表达式),用于匹配 subject (对于 PreToolUse subject 通常就是 tool_name )。只有匹配的组才会被选中。
  3. If守卫 :Claude Code还支持更细粒度的 if 条件,例如 if: "Tool(glob)" ,它可以对 tool_input 的内容进行匹配(如检查命令是否匹配 rm * )。解析函数也会评估这些条件。
  4. 超时继承 :处理器可以定义自己的 timeoutSec ,如果没有,则会从配置的更高层级继承,最终会有一个库函数 effectiveClaudeHandlerTimeoutSec 来计算确切的超时时间。

实操心得 resolveMatchingClaudeHandlersFromInput 是一个非常强大的函数,它把复杂的配置匹配逻辑封装了起来。在编写一个“Hook网关”或“Hook运行器”时,直接使用这个函数可以确保你的执行逻辑与Claude Code Desktop客户端的逻辑保持一致,避免出现“配置里配了但没执行”的诡异问题。

5. 跨平台开发策略与兼容性处理

当你需要编写一个能在多个AI助手平台上运行的Hook时, agent-hook-schemas 提供了两种主要的策略。

5.1 策略一:平台抽象层

创建一个抽象层,根据运行环境或输入特征,动态选择对应的平台模块。

// src/platform-adaptor.ts
import type { SafeParseReturnType } from 'zod';
import type { ClaudeHookInput } from 'agent-hook-schemas/claude';
import type { CodexHookInput } from 'agent-hook-schemas/codex';
// ... 导入其他平台类型

export type UniversalHookInput = ClaudeHookInput | CodexHookInput /* | ... */;
export type HookPlatform = 'claude' | 'codex' | 'gemini' | 'cursor';

export function detectPlatform(rawInput: any): HookPlatform | null {
  // 启发式检测:检查输入中特有的字段
  if (rawInput.hook_event_name) {
    // Claude, Codex, Gemini 使用 PascalCase
    if (rawInput.agent_id !== undefined) return 'claude'; // Claude 特有
    if (rawInput.turn_id !== undefined) return 'codex'; // Codex 特有
    if (rawInput.timestamp !== undefined) return 'gemini'; // Gemini CLI 特有
    // 默认推测为 Claude(作为最常见情况)
    return 'claude';
  } else if (rawInput.hookEventName) {
    // Cursor 使用 camelCase
    return 'cursor';
  }
  return null;
}

export async function parseUniversalInput(rawInput: any): Promise<{platform: HookPlatform; input: UniversalHookInput}> {
  const platform = detectPlatform(rawInput);
  
  if (!platform) {
    throw new Error('无法识别Hook输入来源的平台');
  }

  switch (platform) {
    case 'claude': {
      const { ParseHookInput } = await import('agent-hook-schemas/claude');
      const result = ParseHookInput(rawInput);
      if (!result.success) throw new Error(`Claude输入解析失败: ${result.error.message}`);
      return { platform, input: result.data };
    }
    case 'codex': {
      const { ParseCodexHookInput } = await import('agent-hook-schemas/codex');
      const result = ParseCodexHookInput(rawInput);
      if (!result.success) throw new Error(`Codex输入解析失败: ${result.error.message}`);
      return { platform, input: result.data };
    }
    case 'gemini': {
      const { ParseGeminiHookInput } = await import('agent-hook-schemas/gemini');
      const result = ParseGeminiHookInput(rawInput);
      if (!result.success) throw new Error(`Gemini输入解析失败: ${result.error.message}`);
      return { platform, input: result.data };
    }
    case 'cursor': {
      const { ParseCursorHookInput } = await import('agent-hook-schemas/cursor');
      const result = ParseCursorHookInput(rawInput);
      if (!result.success) throw new Error(`Cursor输入解析失败: ${result.error.message}`);
      return { platform, input: result.data };
    }
  }
}

然后在你的主逻辑中,使用这个适配器:

import { parseUniversalInput } from './platform-adaptor';
import { handleClaudeEvent } from './handlers/claude';
import { handleCodexEvent } from './handlers/codex';
// ...

const { platform, input } = await parseUniversalInput(rawInput);

switch (platform) {
  case 'claude':
    await handleClaudeEvent(input);
    break;
  case 'codex':
    await handleCodexEvent(input);
    break;
  // ...
}

这种策略的优势是逻辑清晰,每个平台的处理器完全独立,便于维护和测试。缺点是会有一些代码重复。

5.2 策略二:通用逻辑与平台细节分离

提取出所有平台共有的核心逻辑,然后将平台特有的输入/输出转换封装成小的适配函数。

// src/core-logic.ts
// 定义我们业务逻辑需要的通用上下文
export interface ToolUseContext {
  platform: string;
  sessionId: string;
  toolName: string;
  command?: string; // 对于Bash工具
  // ... 其他业务相关字段
}

export interface SecurityRule {
  pattern: RegExp;
  action: 'block' | 'warn' | 'allow';
  reason: string;
}

export function evaluateSecurityRules(context: ToolUseContext, rules: SecurityRule[]): { action: string; reason: string } {
  if (context.command) {
    for (const rule of rules) {
      if (rule.pattern.test(context.command)) {
        return { action: rule.action, reason: rule.reason };
      }
    }
  }
  return { action: 'allow', reason: 'No matching security rules' };
}

// src/platform-adaptors.ts
import type { ClaudeHookInput } from 'agent-hook-schemas/claude';
import type { CodexHookInput } from 'agent-hook-schemas/codex';
import { ToolUseContext } from './core-logic';

export function adaptClaudeInputToContext(input: ClaudeHookInput): ToolUseContext | null {
  if (input.hook_event_name === 'PreToolUse' && input.tool_name === 'Bash') {
    // 使用库的解析器获取命令
    const bashParseResult = ParseBashToolInput(input.tool_input);
    if (bashParseResult.success) {
      return {
        platform: 'claude',
        sessionId: input.session_id,
        toolName: input.tool_name,
        command: bashParseResult.data.command,
      };
    }
  }
  return null;
}

export function createClaudeOutput(action: string, reason: string) {
  // 根据action和Claude的协议生成对应的输出对象
  const decisionMap = { block: 'deny', warn: 'ask', allow: 'allow' } as const;
  return HookCommandOutputSchema.parse({
    hookSpecificOutput: {
      hookEventName: 'PreToolUse',
      permissionDecision: decisionMap[action as keyof typeof decisionMap] || 'defer',
      permissionDecisionReason: reason,
    },
  });
}

// 类似地,为Codex、Gemini等编写adaptXxxInputToContext和createXxxOutput函数

主入口文件则负责桥接:

const { platform, input } = await parseUniversalInput(rawInput);
const context = await adaptInputToContext(platform, input); // 调用对应的适配函数

if (context) {
  const securityResult = evaluateSecurityRules(context, mySecurityRules);
  const output = createPlatformOutput(platform, securityResult); // 调用对应的输出创建函数
  console.log(JSON.stringify(output));
}

这种策略减少了重复的业务逻辑,但增加了适配层的复杂度。适合那些核心规则统一,但输入输出格式差异大的场景。

选择建议 :如果你的Hook逻辑简单,且希望为每个平台做深度优化,用策略一。如果你的安全规则、日志逻辑等核心功能很复杂,且在不同平台间一致,用策略二。

6. 常见问题排查与性能优化

6.1 典型问题与解决方案

问题现象 可能原因 排查步骤与解决方案
Hook脚本无任何输出,AI助手直接执行了命令 1. Hook脚本执行超时并被终止。
2. 脚本抛出未捕获的异常,进程崩溃。
3. 配置文件路径错误或格式错误,Hook未生效。
1. 检查超时设置 :在Hook配置中增加 timeoutSec (如30秒),并在脚本开始时用 console.error 输出日志,看是否能被捕获。
2. 加强错误处理 :用 try-catch 包裹整个 main() 函数,在 catch 中输出一个合法的拒绝JSON到 stdout
3. 验证配置 :使用 parseClaudeSettings() 等库函数验证你的配置文件。检查Claude Code的日志文件,看是否有加载Hook配置的错误信息。
Hook输出了内容,但AI助手报“无效的Hook响应” 输出的JSON格式不符合平台要求。可能是缺少必需字段、字段类型不对、或使用了错误的字段名。 1. 使用Schema验证输出 :务必使用如 HookCommandOutputSchema.parse() safeParse() 来生成输出对象,而不是手动拼接JSON。
2. 注意严格模式 :对于Codex,确保使用严格模式生成输出(库的Codex输出Schema通常已内置 .strict() )。
3. 检查字段值 :例如,Codex的 decision 字段可能只接受 "approve" "block" ,而Claude用 "allow" / "deny"
只有部分命令被Hook拦截,有些漏掉了 1. Matcher配置不正确,没有匹配到所有 Bash 工具调用。
2. Hook配置的优先级被其他配置覆盖了。
3. 命令在子Shell或复杂管道中, tool_input 的格式可能不同。
1. 检查Matcher :尝试将 matcher 设置为 ".*" (匹配所有subject)进行测试。确认你的正则表达式是否正确。
2. 检查配置合并 :使用 mergeClaudeHooksFiles resolveMatchingClaudeHandlersFromInput 在本地模拟测试,看最终哪些Handler生效。
3. 深入解析tool_input :打印出 tool_input 的完整内容,检查命令字符串的格式。有些AI助手可能会对命令进行转义或拆分。
Hook脚本执行速度慢,影响用户体验 1. 脚本启动慢(如TypeScript即时编译)。
2. 脚本内执行了同步的耗时操作(如大量文件IO、网络请求)。
3. 配置了太多Hook或复杂Matcher,解析匹配耗时。
1. 预编译 :将TypeScript预编译为JavaScript再部署,避免运行时编译开销。
2. 异步与非阻塞 :确保所有IO操作都是异步的。对于网络请求,设置合理的超时和重试。
3. 优化匹配逻辑 :避免在Hook脚本内进行复杂的文件系统遍历或数据库查询。如果必须,考虑缓存结果。
4. 精简配置 :评估是否每个事件都需要Hook。对于高性能要求的场景,考虑将多个检查合并到一个脚本中。
在团队中,某个成员的Hook不生效 1. 配置文件未提交到版本库,或路径不一致。
2. 成员本地环境缺少Hook脚本的运行时(如Node.js版本不对)。
3. 成员本地的更高优先级配置(如用户全局配置)禁用了Hook。
1. 标准化配置位置 :在项目文档中明确Hook配置( .claude/hooks.json )应放在项目根目录,并加入版本控制。
2. 提供安装脚本 :在 package.json 中提供 postinstall 脚本,检查或安装必要依赖。
3. 教育团队成员 :说明配置的优先级。可以提供一个脚本,帮助成员检查最终生效的配置(使用本库的合并函数)。

6.2 性能优化实践

  1. 懒加载与动态导入 :如果你的Hook脚本很大,但只在特定事件触发,可以考虑使用动态导入( import() )来按需加载处理模块,减少启动时的内存占用和解析时间。
  2. 缓存配置解析结果 mergeClaudeHooksFiles 和解析配置文件的操作可能比较耗时。如果Hook脚本是常驻进程(某些高级用法),或者会被频繁调用(虽然不常见),可以将解析后的配置缓存起来。
  3. 优化正则表达式 :Matcher中使用的正则表达式要尽量简单高效。避免使用贪婪匹配 .* 开头或结尾,这可能导致回溯灾难。对于简单的字符串匹配,使用 matcher: "ExactString" matcher: "^ExactString$" 更直接。
  4. 避免同步操作 :在Node.js中,同步的 fs.readFileSync JSON.parse (对大对象)会阻塞事件循环。始终使用异步API,确保Hook能快速响应。
  5. 设置合理的超时 :不仅要在配置中设置 timeoutSec ,在Hook脚本内部发起任何子进程或网络请求时,也要设置超时。一个卡住的子进程会导致整个Hook超时,但内部超时可以提供更清晰的错误信息。

6.3 调试与日志记录

构建健壮的Hook离不开日志。建议建立一个简单的日志工具,根据环境变量控制日志级别。

// src/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';

const LOG_LEVEL = (process.env.HOOK_LOG_LEVEL || 'warn') as LogLevel;
const LEVEL_PRIORITY: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };

function shouldLog(level: LogLevel): boolean {
  return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[LOG_LEVEL];
}

export function log(level: LogLevel, message: string, data?: any) {
  if (!shouldLog(level)) return;
  
  const entry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    sessionId: process.env.CLAUDE_SESSION_ID, // 如果环境变量中有的话
    ...(data && { data: JSON.stringify(data) }), // 避免循环引用
  };
  
  // 输出到stderr,避免干扰stdout的JSON响应
  console.error(JSON.stringify(entry));
}

// 使用
import { log } from './logger';
log('info', 'Hook started', { hookEvent: input.hook_event_name });
log('debug', 'Parsed tool input', bashInput);

在开发时,设置 HOOK_LOG_LEVEL=debug ,在生产环境设置为 HOOK_LOG_LEVEL=error 。这些日志可以从AI助手的日志窗口或系统日志中查看,是排查问题的宝贵资料。

最后,记住Hook脚本是用户与AI助手交互的关键拦截点,它的稳定性和性能直接影响用户体验。从简单的命令检查开始,逐步迭代,充分利用 agent-hook-schemas 提供的类型安全性和工具函数,可以让你构建出既强大又可靠的AI助手扩展。

更多推荐