基于Zod统一AI编程助手钩子开发:agent-hook-schemas实战指南
钩子(Hook)是软件开发中常见的拦截与扩展机制,通过在程序执行的关键节点插入自定义逻辑,实现对行为的监控与管控。其原理是基于事件驱动架构,允许开发者在特定操作前后注入代码,从而在不修改核心逻辑的前提下增强功能。这一技术在自动化流程、安全审计和系统集成中具有重要价值,广泛应用于CI/CD流水线、API网关和开发工具链等场景。随着AI编程助手(如Claude Code、Cursor)的普及,为其构建
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配置合并与解析
这种设计的好处显而易见:
- 按需引入,减小体积 :如果你只开发Claude Code的钩子,就只导入
agent-hook-schemas/claude,打包工具可以轻松地做Tree Shaking,避免引入无用代码。 - 职责清晰,避免污染 :每个平台的独特逻辑被封装在各自的命名空间下。比如,Codex有严格(
strict)的stdout格式要求,而Claude是宽松(loose)的,这些差异被隔离在各自的模块中,不会互相影响。 - 易于维护和扩展 :未来如果支持新的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为例,它允许在三个地方定义钩子:
- 用户全局配置 (
~/.config/claude/claude_desktop_config.json): 适用于所有项目。 - 项目级配置 (
项目根目录/.claude/hooks.json): 适用于特定项目。 - 本地配置 (IDE工作区设置): 优先级最高。
当同一个事件(如 PreToolUse )在多层配置中都有定义时,Claude需要一套规则来合并它们。 agent-hook-schemas 的 claude-hooks-integration 模块,就是这套规则的官方实现。
mergeClaudeHooksFiles 函数的核心逻辑是 优先级合并与重置 。它接受一个配置对象数组,按优先级从低到高传递(如 [用户配置, 项目配置] )。合并时,高优先级的配置会覆盖低优先级的同名事件配置。特别需要注意的是 disableAllHooks: true 这个开关,如果它在某一层被设置,它会 重置 所有更低优先级的钩子配置,这是一个需要小心处理的行为。
合并得到最终配置后,面对一个具体的钩子事件,如何知道要执行哪个脚本呢?这就是 resolveMatchingClaudeHandlersFromInput 函数的工作。它内部会做两件事:
- 主题解析 :根据事件类型和上下文,解析出
subject。例如,对于PreToolUse事件且tool_name为Bash,subject就是Bash。 - 匹配器评估 :遍历该事件下所有配置的处理器,检查其
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 编写核心钩子逻辑
我们的钩子脚本需要做以下几件事:
- 从标准输入(
stdin)读取AI助手传递过来的JSON数据。 - 解析数据,判断事件类型。
- 如果是
PreToolUse或preToolUse(对应Cursor)事件,并且工具是Bash或Shell,则检查命令内容。 - 如果命令包含危险模式,则输出“拒绝”的响应;否则输出“允许”。
- 将响应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();
代码解析与注意事项:
- 运行时选择 :示例使用了
Bun.stdin.text()。如果你使用Node.js,需要替换为fs.readFileSync(0, 'utf-8')来读取stdin。确保你的脚本运行环境支持相应的API。 - 平台探测 :我们依次尝试用Claude和Cursor的解析器来解析输入。这种顺序取决于你的主要使用场景。也可以根据输入中的某些特征字段(如是否存在
cursor_version)来智能判断。 - 事件名大小写 :注意Claude使用
PascalCase(如PreToolUse),而Cursor使用camelCase(如preToolUse)。在输出响应时,hookSpecificOutput.hookEventName字段需要与输入平台期望的格式一致,示例中统一用了PreToolUse,对于Claude和Codex是兼容的,但严格来说,为Cursor写钩子可能需要适配其输出格式(尽管Cursor可能只读stdin,不依赖特定stdout)。 - 输出验证 :使用
HookCommandOutputSchema.parse()而不是直接JSON.stringify一个对象,这能确保我们输出的JSON完全符合Claude Code的stdout格式要求,避免因细微格式错误导致钩子失效。 - 错误处理 :任何未捕获的异常都可能导致钩子脚本非正常退出,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文件)。钩子脚本本身变得非常轻薄,它只负责三件事:
- 解析AI助手传入的事件。
- 根据事件上下文(如会话ID、项目路径、工具名、命令)向“策略中心”查询决策。
- 将决策结果(允许/拒绝)返回给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": "禁止编辑敏感的环境变量或密钥文件"
}
]
}
系统工作流程 :
- AI助手(如Claude Code)触发
PreToolUse事件。 - 钩子脚本
gateway.js被调用,读取事件数据。 - 脚本首先查找项目根目录下的
.ai-policy.json,然后查找用户全局策略。 - 按顺序评估规则:如果事件、工具名匹配,并且条件(如命令正则匹配)满足,则采用该规则的决策。
- 脚本将决策(
allow/deny/ask)及原因返回给AI助手。 - 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。
合并过程是:
- 从低优先级(用户层)开始。
- 遇到项目层的
disableAllHooks: true,这会 清空 当前累积的所有钩子配置(即钩子A被丢弃)。 - 然后继续合并项目层自己的配置(钩子B被加入)。
- 最后合并高优先级的本地层配置(钩子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 通常输出到宿主机的标准错误流。建立有效的日志机制至关重要。
推荐做法 :
- 结构化日志 :使用
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, })); - 日志级别 :实现
DEBUG、INFO、WARN、ERROR等级别,通过环境变量控制输出粒度。 - 持久化 :将重要的决策日志(尤其是
deny决策)写入文件或发送到日志聚合服务(如Loki、ELK),便于事后审计和统计分析。 - 调试模式 :开发时,可以暂时让钩子脚本将本应输出到
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 提供的类型安全和工具函数,结合系统的日志和测试,能够帮你构建出既强大又稳定的自动化助手管控层。
更多推荐



所有评论(0)