1. Claude Code Mods 到底是个什么东西
第一次听到“Claude Code Mods”这个词,很多人会以为是某个插件市场或者第三方魔改版本。其实不是。Claude Code 本身是 Anthropic 推出的一个跑在终端里的编程助手,你可以把它理解成一个住在命令行里的结对程序员——它能读你的项目文件、执行命令、改代码、跑测试。而所谓 Mods,指的是围绕 Claude Code 构建的一套扩展机制,核心手段就是Hook。
Hook 这个词在编程里出现频率极高,本质就是“钩子”——在某个特定时机插入一段自定义逻辑。Claude Code 的 Hook 机制允许你在它执行工具调用前后、会话开始结束时、用户提交提示词时等关键节点,挂上自己的脚本。这些脚本可以用 JS、TS 或者任何你终端能跑的语言来写。于是你就能做到:在 Claude 改完文件后自动跑一遍 lint,在它执行危险命令前拦截并弹窗确认,在会话结束时把对话记录归档到本地数据库,甚至用 JS 在终端里画出一个交互式界面来展示 Claude 的工作状态。
这套东西解决的核心问题是:通用 AI 编程助手和你的具体工作流之间的最后一公里。Claude Code 开箱即用的能力已经不错,但每个团队、每个人的项目结构、代码规范、安全要求都不一样。Mods 就是让你不用等官方更新,自己动手把 Claude Code 改造成贴合自己习惯的形态。
适合谁来研究这个?三类人最受益。第一类是每天泡在终端里的后端或全栈工程师,他们本来就熟悉 shell 和 Node.js,上手 Hook 几乎没有门槛。第二类是对 AI 辅助编程有深度定制需求的技术负责人,他们需要把 Claude Code 接入团队现有的 CI/CD、代码审查、日志体系。第三类是喜欢折腾终端工具的效率爱好者,哪怕不写复杂逻辑,用 Hook 做点自动化提醒也能明显提升体验。
我自己的感受是,Claude Code 不加 Mods 就像一把没开刃的刀,能用,但不够顺手。加上 Hook 之后,它才真正变成你工作流的一部分,而不是一个外挂的聊天窗口。
2. Hook 机制的核心原理与设计思路
2.1 Hook 到底在哪些时机被触发
Claude Code 的 Hook 不是随便挂的,它有一套明确的事件模型。根据我实际使用和查阅文档的经验,常见的事件类型包括:
- PreToolUse:在 Claude 决定调用某个工具(比如读写文件、执行 bash 命令)之前触发。这是做安全拦截的最佳位置。
- PostToolUse:工具调用完成之后触发。适合做格式化、lint、日志记录。
- Notification:Claude 需要向用户发出通知时触发,比如任务完成或需要确认。
- Stop:Claude 完成一轮响应后触发,可以用来做会话收尾。
- SubagentStop:子代理任务结束时触发。
- UserPromptSubmit:用户提交提示词时触发,可以在内容进入模型前做预处理。
每个事件触发时,Claude Code 会把上下文信息以 JSON 格式通过标准输入传给 Hook 脚本,脚本处理完后通过标准输出返回结果。这个设计非常 Unix 哲学——用管道和 JSON 做进程间通信,语言无关,简单可靠。
注意:不同版本的 Claude Code 支持的事件类型可能有差异,建议先用
claude --help或查看官方文档确认你当前版本支持哪些事件。
2.2 为什么选择 JS/TS 来写 Hook
热词里反复出现 JS、TS,这不是偶然。Hook 脚本本质上就是一个个可执行文件,你用 Python、Ruby、Go 写都行。但 JS/TS 有几个明显优势:
第一,Node.js 几乎是前端和全栈开发者的标配运行时,不需要额外装环境。第二,JSON 处理在 JS 里天然顺手,JSON.parse和JSON.stringify就是为这种场景生的。第三,TS 能提供类型提示,Hook 的输入输出结构比较复杂,有类型约束能少踩很多坑。第四,npm 生态里有大量现成的库,比如用chalk做终端着色,用inquirer做交互式提问,用blessed或ink在终端里画界面。
我试过用 Python 写 Hook,功能上完全没问题,但每次都要处理虚拟环境和依赖安装,团队协作时反而麻烦。后来统一用 TS 写,配合tsx直接运行,省去了编译步骤,体验流畅很多。
2.3 Hook 的配置方式与优先级
Claude Code 的 Hook 配置通常放在项目的.claude目录下,或者用户主目录的全局配置里。配置格式一般是 JSON,指定事件类型、匹配规则和要执行的命令。比如:
{ "hooks": { "PostToolUse": [ { "matcher": "Write|Edit", "command": "npx tsx .claude/hooks/format.ts" } ] } }这里的matcher是一个正则,用来过滤哪些工具调用会触发这个 Hook。Write|Edit表示只有写文件和编辑文件的操作才触发。这种设计让你可以针对不同工具做不同处理,避免所有操作都跑一遍脚本造成性能浪费。
优先级方面,项目级配置通常覆盖全局配置。这意味着你可以在全局设一套通用的安全 Hook,然后在具体项目里针对性地调整。这个层级关系很符合直觉,和.gitignore的覆盖逻辑类似。
3. 从零搭建一个可用的 Hook 开发环境
3.1 安装 Claude Code 与前置准备
安装 Claude Code 本身不复杂,官方推荐的方式是通过 npm 全局安装:
npm install -g @anthropic-ai/claude-code装完之后在终端输入claude就能启动。第一次运行会引导你完成认证配置。这里有个常见坑:如果你用的是公司电脑,npm 全局目录可能没有写权限,会报no write permission to npm prefix这类错误。解决办法是配置 npm 的全局目录到用户目录下:
npm config set prefix ~/.npm-global export PATH=~/.npm-global/bin:$PATH把上面这行 export 加到你的.bashrc或.zshrc里,以后就不会再遇到权限问题。这个坑我踩过两次,第一次折腾了半小时才反应过来是权限问题。
Node.js 版本建议用 18 以上,最好 20 LTS。TS 方面,全局装一个tsx会让后续开发方便很多:
npm install -g tsxtsx的好处是直接运行 TS 文件,不需要先tsc编译。对于 Hook 这种小脚本来说,省一步是一步。
3.2 目录结构设计与初始化
一个清晰的项目结构能让后续维护轻松很多。我通常这样组织:
.claude/ hooks/ pre-tool-use.ts post-tool-use.ts user-prompt-submit.ts lib/ logger.ts utils.ts settings.json.claude/settings.json放 Hook 配置,hooks目录放脚本,lib放公共函数。这样拆分的好处是,当你有多个 Hook 需要共享日志或工具函数时,不用复制粘贴。
初始化的时候,先在项目根目录创建.claude文件夹,然后写一个最简单的 Hook 测试链路是否通。比如一个PostToolUse的脚本,只做一件事:把收到的 JSON 打印到日志文件。
// .claude/hooks/post-tool-use.ts import fs from 'fs'; let input = ''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const data = JSON.parse(input); fs.appendFileSync('/tmp/claude-hook.log', JSON.stringify(data) + '\n'); process.exit(0); });配置里加上对应的条目,然后让 Claude 执行一次文件写入操作,看看日志文件里有没有内容。这一步验证通过,说明整条链路是通的,后面再往上加逻辑就不会抓瞎。
3.3 调试 Hook 的实用技巧
Hook 调试最头疼的是它跑在子进程里,你看不到 console.log 的输出。我的做法是统一写日志文件,用一个简单的 logger 封装:
// .claude/hooks/lib/logger.ts import fs from 'fs'; const LOG_PATH = '/tmp/claude-hook-debug.log'; export function log(label: string, data: unknown) { const line = `[${new Date().toISOString()}] ${label}: ${JSON.stringify(data)}\n`; fs.appendFileSync(LOG_PATH, line); }然后在每个关键节点调用log。调试完记得把日志级别调高或者关掉,否则日志文件会迅速膨胀。我见过有人忘了关调试日志,跑了一天下来文件好几个 G。
另一个技巧是用tail -f实时看日志:
tail -f /tmp/claude-hook-debug.log这样你在另一个终端操作 Claude Code 时,能立刻看到 Hook 的执行情况,排查问题效率高很多。
4. 用 Hook 给 Claude 加上实用工具能力
4.1 自动格式化与 lint:PostToolUse 的经典用法
这是最容易见效的一个 Hook。每次 Claude 写完或改完文件,自动跑一遍 Prettier 和 ESLint,保证代码风格统一。配置如下:
{ "hooks": { "PostToolUse": [ { "matcher": "Write|Edit", "command": "npx tsx .claude/hooks/format.ts" } ] } }脚本内容:
// .claude/hooks/format.ts import { execSync } from 'child_process'; import path from 'path'; let input = ''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const data = JSON.parse(input); const filePath = data.tool_input?.file_path; if (!filePath) process.exit(0); const ext = path.extname(filePath); try { if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) { execSync(`npx prettier --write "${filePath}"`, { stdio: 'ignore' }); execSync(`npx eslint --fix "${filePath}"`, { stdio: 'ignore' }); } } catch (e) { // 格式化失败不阻塞主流程 } process.exit(0); });这里有个关键点:Hook 脚本的退出码决定是否阻塞 Claude 的后续操作。退出码 0 表示成功,非 0 可能会让 Claude 认为工具调用失败。所以格式化这种非关键操作,即使出错也应该吞掉异常,返回 0。
实操心得:Prettier 和 ESLint 在大项目里可能比较慢,如果每个文件都跑一遍会明显拖慢 Claude 的响应速度。我的做法是只对改动文件跑,并且加一个简单的缓存机制,记录最近处理过的文件哈希,没变就跳过。
4.2 危险命令拦截:PreToolUse 的安全防线
Claude 有时候会执行一些破坏性命令,比如rm -rf、git reset --hard、DROP TABLE。虽然它通常比较谨慎,但加一道保险总没错。PreToolUse Hook 可以在命令执行前拦截:
// .claude/hooks/pre-tool-use.ts const DANGEROUS_PATTERNS = [ /rm\s+-rf\s+\//, /git\s+reset\s+--hard/, /git\s+push\s+--force/, /DROP\s+TABLE/i, /DELETE\s+FROM\s+\w+\s*;/i, ]; let input = ''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const data = JSON.parse(input); const command = data.tool_input?.command || ''; for (const pattern of DANGEROUS_PATTERNS) { if (pattern.test(command)) { console.error(`拦截危险命令: ${command}`); process.exit(2); // 非 0 退出码阻止执行 } } process.exit(0); });退出码 2 是一个约定,表示“阻止这次工具调用”。Claude 收到这个信号后会把错误信息展示给用户,而不是继续执行。这个机制相当于给你的 AI 助手装了一个刹车片。
我实际用下来,这个 Hook 拦截过好几次 Claude 想直接git push --force的情况。虽然它可能是出于好意想帮你覆盖远程分支,但在团队协作场景下这很危险。有了拦截,它会转而询问你,这就安全多了。
4.3 会话记录归档:Stop 事件的妙用
每次和 Claude 的对话都是宝贵的上下文,尤其是那些解决了复杂问题的会话。用 Stop Hook 可以把对话自动归档:
// .claude/hooks/stop.ts import fs from 'fs'; import path from 'path'; const ARCHIVE_DIR = path.join(process.env.HOME!, '.claude-archive'); let input = ''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const data = JSON.parse(input); if (!fs.existsSync(ARCHIVE_DIR)) { fs.mkdirSync(ARCHIVE_DIR, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filePath = path.join(ARCHIVE_DIR, `${timestamp}.json`); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); process.exit(0); });归档之后,你可以用grep或jq搜索历史会话,找回之前解决过的问题。我习惯每周花十分钟翻一遍归档,把有价值的解决方案整理到团队知识库里。
5. 在终端里画界面:用 JS 做交互式 Hook
5.1 为什么要在终端画界面
Hook 默认是静默执行的,用户看不到任何反馈。但有些场景下,你需要让用户做选择。比如 Claude 要执行一个可能有风险的操作,你想弹出一个确认框;或者会话结束时,你想展示一个统计面板,告诉用户这次改了多少文件、跑了多少测试。
终端界面库这时候就派上用场了。JS 生态里有几个成熟的选择:
| 库名 | 特点 | 适用场景 |
|---|---|---|
inquirer | 交互式问答,支持选择、输入、确认 | 需要用户决策的 Hook |
chalk | 终端着色,轻量 | 日志美化、状态提示 |
ora | 加载动画 | 长时间操作的进度提示 |
ink | 用 React 写终端界面 | 复杂布局、实时刷新 |
blessed | 老牌终端 UI 库 | 全屏应用、表格展示 |
对于大多数 Hook 场景,inquirer+chalk的组合就够用了。ink适合更复杂的场景,但学习曲线陡一些。
5.2 用 inquirer 做危险操作确认
把前面的危险命令拦截升级一下,不是直接阻止,而是弹窗让用户确认:
// .claude/hooks/pre-tool-use-confirm.ts import inquirer from 'inquirer'; import chalk from 'chalk'; const DANGEROUS_PATTERNS = [ { pattern: /rm\s+-rf/, label: '递归删除' }, { pattern: /git\s+reset\s+--hard/, label: '硬重置' }, { pattern: /git\s+push\s+--force/, label: '强制推送' }, ]; let input = ''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', async () => { const data = JSON.parse(input); const command = data.tool_input?.command || ''; const matched = DANGEROUS_PATTERNS.find(p => p.pattern.test(command)); if (!matched) { process.exit(0); } console.log(chalk.yellow(`\n检测到${matched.label}操作:`)); console.log(chalk.gray(command)); const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message: '确定要执行吗?', default: false, }, ]); process.exit(confirmed ? 0 : 2); });这里有个细节要注意:inquirer是异步的,所以process.stdin.on('end')的回调要写成 async 函数。另外,Hook 脚本的标准输入被 Claude 占用,inquirer需要从/dev/tty读取用户输入。在某些环境下可能需要显式指定:
const { confirmed } = await inquirer.prompt([...], { input: process.stdin, output: process.stdout, });如果遇到输入无响应的情况,检查一下是不是 stdin 被重定向了。我在这上面卡过一次,后来发现是 Claude Code 把 stdin 管道给了 Hook,导致 inquirer 读不到键盘输入。解决办法是打开/dev/tty作为输入源。
5.3 用 ink 做一个会话统计面板
如果你想要更炫的效果,ink可以让你用 React 组件的方式写终端界面。下面是一个会话结束时的统计面板示例:
// .claude/hooks/stop-dashboard.tsx import React from 'react'; import { render, Box, Text } from 'ink'; interface Stats { filesChanged: number; commandsRun: number; duration: number; } const Dashboard: React.FC<{ stats: Stats }> = ({ stats }) => ( <Box flexDirection="column" borderStyle="round" padding={1}> <Text bold color="cyan">会话统计</Text> <Text>改动文件: <Text color="green">{stats.filesChanged}</Text></Text> <Text>执行命令: <Text color="yellow">{stats.commandsRun}</Text></Text> <Text>耗时: <Text color="magenta">{stats.duration}s</Text></Text> </Box> ); let input = ''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const data = JSON.parse(input); const stats: Stats = { filesChanged: data.files_changed || 0, commandsRun: data.commands_run || 0, duration: Math.round((data.duration_ms || 0) / 1000), }; render(<Dashboard stats={stats} />); setTimeout(() => process.exit(0), 100); });注意最后那个setTimeout,因为ink的渲染是异步的,直接process.exit可能导致界面还没画完就退出了。给个 100ms 的缓冲比较稳妥。
提示:
ink需要 React 作为依赖,记得在项目里npm install react ink。如果你的 Hook 脚本是全局使用的,建议把依赖装在全局或者用npx动态拉取。
6. 常见问题与排查技巧实录
6.1 Hook 不生效的排查思路
Hook 配好了但没反应,这是最常见的问题。我整理了一个排查清单,按顺序检查:
| 检查项 | 可能问题 | 解决方法 |
|---|---|---|
| 配置文件位置 | 放错目录 | 确认在.claude/settings.json或全局配置 |
| JSON 格式 | 语法错误 | 用jq . settings.json验证 |
| matcher 正则 | 不匹配工具名 | 先用.*测试,再逐步收窄 |
| 脚本权限 | 没有执行权限 | chmod +x或确保用解释器调用 |
| 运行时路径 | 找不到 node/tsx | 用绝对路径或在配置里指定 PATH |
| 退出码 | 非 0 导致静默失败 | 临时改成总是exit 0测试 |
我遇到最多的是 matcher 写错。比如工具名是Write,你写成了write,大小写不匹配就不触发。JS 正则默认区分大小写,要么写对,要么加i标志。
6.2 性能问题的优化经验
Hook 是同步阻塞的,脚本跑得慢会直接拖慢 Claude 的响应。几个优化方向:
第一,减少不必要的进程启动。每次 Hook 触发都npx tsx会有一两百毫秒的启动开销。如果 Hook 逻辑简单,可以考虑用纯 JS 写,直接node运行。或者把多个 Hook 合并成一个脚本,根据事件类型分发。
第二,缓存重复计算。比如格式化 Hook,如果文件内容没变,就没必要再跑一遍 Prettier。可以用文件哈希做缓存键。
第三,异步化非关键操作。日志归档、统计上报这类不影响主流程的操作,可以 fork 一个子进程去做,主进程立即返回。
import { spawn } from 'child_process'; // 主流程立即返回 const child = spawn('node', ['.claude/hooks/archive.js'], { detached: true, stdio: 'ignore', }); child.unref(); process.exit(0);这样归档操作在后台跑,不阻塞 Claude。
6.3 跨平台兼容性坑点
如果你在 Windows 和 macOS/Linux 之间切换,Hook 脚本要注意路径分隔符和命令差异。path.join会自动处理分隔符,但如果你在脚本里硬编码了/,在 Windows 上就可能出问题。
另一个坑是 shell 命令。execSync('rm -rf ...')在 Windows 上会失败,因为 Windows 没有rm。跨平台的话,用 Node.js 的fs.rmSync代替 shell 命令更稳妥。
还有换行符问题。Windows 用\r\n,Unix 用\n。处理文本时用os.EOL或者统一转成\n再处理。
7. 进阶玩法:把 Hook 串成工作流
单个 Hook 能力有限,但多个 Hook 组合起来就能形成完整的工作流。比如我现在的配置:
UserPromptSubmit:自动在提示词里注入当前 git 分支和最近提交信息,让 Claude 有更多上下文。PreToolUse:拦截危险命令,弹窗确认。PostToolUse:自动格式化、lint、跑相关单元测试。Stop:归档会话,更新统计面板。
这一套下来,Claude Code 就不再是一个孤立的工具,而是嵌入了我的开发流程。它知道我在哪个分支工作,改完代码自动帮我检查,危险操作会问我,会话结束有记录。这种体验上的提升,比单纯换个更强的模型要明显得多。
Hook 的另一个进阶用法是条件触发。比如只在特定目录下的文件改动时才跑测试,或者只在工作日的工作时间才发通知。这些逻辑都可以在脚本里用简单的 if-else 实现。
最后分享一个我最近在用的技巧:用 Hook 把 Claude 的每次代码改动同步到一个本地 SQLite 数据库,记录文件、时间、改动内容摘要。积累一段时间后,你可以分析出哪些文件最常被改、哪些时间段效率最高。这种数据驱动的自我观察,对优化工作方式很有帮助。
import Database from 'better-sqlite3'; const db = new Database('/tmp/claude-changes.db'); db.exec(` CREATE TABLE IF NOT EXISTS changes ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, summary TEXT ) `); // 在 PostToolUse 里插入记录 const stmt = db.prepare('INSERT INTO changes (file_path, summary) VALUES (?, ?)'); stmt.run(filePath, summary);这个数据库后续可以用任何 BI 工具可视化,或者写个简单的 CLI 查询。我目前用它来回顾每周的编码重点,比凭记忆靠谱多了。