1. 从终端里长出来的“改装件”:Claude Code Mods 到底在解决什么问题
第一次听到 Claude Code Mods 这个词,很多人会下意识以为它是某种插件市场,或者像浏览器扩展那样点一下“安装”就完事。实际接触下来你会发现,它更像是一套“给命令行里的 AI 助手做外科手术”的机制。Claude Code 本身是跑在终端里的编码助手,能读文件、改代码、执行命令,而 Mods 做的事情,是在这个基础之上,让你可以往它的工作流里塞进自己的工具、自己的界面、自己的钩子逻辑。
我最初关注这个方向,是因为一个很具体的痛点:终端里的 AI 助手虽然能干活,但它的“感官”是有限的。它看不到你项目里某个私有服务的状态,没法调用你团队内部的构建脚本,也没法在它准备执行危险命令之前先拦一道。这些需求官方不可能全都覆盖,于是 Mods 这类扩展机制就有了存在的意义。它要解决的核心问题,就是把通用的 AI 编码助手,改造成贴合你自己工程环境的专用助手。
从关键词里能看出几个明确的技术锚点:TypeScript、JS、hook。这三个词基本框定了 Mods 的技术底座。TypeScript 和 JS 说明扩展逻辑是用前端生态的语言写的,这对大量 Web 开发者来说门槛很低;hook 则点明了它的扩展方式不是简单的“注册一个函数”,而是通过钩子(hook)挂载到 Claude Code 的生命周期里,在特定时机介入。理解这一点很关键,因为它决定了你能做什么、不能做什么。
这篇文章适合几类人看:一是已经在用 Claude Code、想进一步榨干它能力的开发者;二是对终端工具扩展机制感兴趣、想理解 hook 设计思路的工程师;三是手上有 TypeScript/JS 基础,想找个真实场景练手的人。我会从 Mods 的定位讲起,拆解它的 hook 机制和 TypeScript 类型体系,然后给出一套可复现的自定义工具和终端界面实现思路,最后把我踩过的坑和排查过程完整摊开。全程按“为什么这么设计”来讲,而不是丢一堆 API 让你自己猜。
2. Mods 的定位:它不是插件,是挂在生命周期上的钩子
2.1 为什么是 hook 而不是传统插件
传统插件模型通常是“注册—调用”两步:你写一个函数,注册到某个扩展点,宿主在需要时调用它。这种模型的问题是,扩展点往往是宿主预先定义好的有限几个,你只能在这些点上做文章。而 Claude Code Mods 走的是 hook 路线,hook 的本质是在宿主执行流程的特定节点上插入回调,这个节点可以非常细,细到“每次工具调用之前”“每次消息渲染之后”。
这两者的差别,用生活化的类比就是:插件像是给房子加一个固定的飘窗,位置是开发商定好的;hook 像是给房子装一套智能家居的触发器,你可以在开门时、开灯时、温度变化时分别挂上不同的动作。显然后者的自由度大得多,但代价是你得理解房子的“事件流”是怎么走的。
我实测下来的感受是,Mods 的 hook 机制让扩展的粒度变得非常细。你可以只关心“工具执行前”这一个瞬间,也可以同时挂多个 hook 形成一条处理链。这种设计对 TypeScript 开发者特别友好,因为 hook 的签名、参数、返回值都可以用类型精确描述,编辑器里能直接补全,写错了当场报红。
2.2 hook 的触发时机决定了能力边界
要玩转 Mods,第一件事是把 hook 的触发时机摸清楚。根据我实际调试的经验,常见的 hook 节点大致可以归为几类,下面这张表是我自己整理的心智模型,不是官方文档的照搬,而是按“你想干什么”来反推“该挂哪个 hook”。
| 你想做的事 | 对应的 hook 时机 | 典型用途 |
|---|---|---|
| 拦截或改写 AI 准备执行的命令 | 工具调用前 | 危险命令拦截、命令参数注入 |
| 记录 AI 的每一步操作 | 工具调用后 | 审计日志、操作回放 |
| 给 AI 补充上下文 | 消息组装前 | 注入项目私有信息、环境变量 |
| 自定义终端输出样式 | 渲染阶段 | 彩色输出、进度条、表格美化 |
| 在会话开始/结束时做事 | 会话生命周期 | 初始化配置、清理临时文件 |
这张表的价值在于,它把“我想实现某个功能”直接映射到“我该挂哪个 hook”。很多人上手 Mods 卡住,不是因为不会写 TypeScript,而是因为没想清楚自己的需求落在哪个时机上。比如你想拦截危险命令,却挂在了“工具调用后”,那命令早就执行完了,拦截毫无意义。时机选错,逻辑再对也是白搭。
2.3 一个最小可用的 hook 长什么样
先给一个最朴素的例子,让你对 hook 的形态有个直观感受。下面这段是 TypeScript 写的,挂在工具调用前,作用是打印出即将执行的命令:
import type { ToolCallHook } from "claude-code-mods"; const logBeforeTool: ToolCallHook = async (context) => { const { toolName, args } = context; console.log(`[mods] 即将调用工具: ${toolName}`); console.log(`[mods] 参数: ${JSON.stringify(args, null, 2)}`); return context; }; export default logBeforeTool;这段代码里几个点值得说。第一,ToolCallHook是一个类型,它约束了函数的参数和返回值,这就是 TypeScript 在 Mods 里的价值——你不用去翻文档猜 context 里有什么字段,编辑器会告诉你。第二,函数是async的,说明 hook 支持异步操作,你可以在里面读文件、发请求、查数据库。第三,它返回了context,这个返回值很关键,因为 hook 链是可以改写上下文的,你返回什么,下一个 hook 或宿主就拿到什么。
提示:hook 的返回值语义一定要搞清楚。有的 hook 返回修改后的 context 表示“继续并采用我的修改”,有的 hook 返回特定值表示“中断流程”。搞混了会出现“我明明拦截了但命令还是执行了”这种诡异现象。
3. TypeScript 类型体系:让 hook 写起来不靠猜
3.1 类型声明文件是 Mods 开发的“地图”
热词里有一条“typescript types 文件夹的声明文件 如何使用”,这其实正好戳中了 Mods 开发的一个关键点。Mods 的 SDK 大量依赖.d.ts声明文件来暴露类型,你import type进来的那些接口,背后都是声明文件在支撑。理解声明文件怎么组织,你就能在没有网络、没有文档的情况下,靠编辑器的跳转把整个 API 摸清楚。
我一般的做法是,装好 SDK 之后,直接在node_modules里找到对应的.d.ts文件,从入口类型开始顺着interface和type往下跳。比如从ToolCallHook跳到它的context参数类型,再跳到context里各个字段的类型。这个过程比读文档快,因为类型定义是精确的,不会像文档那样有滞后或歧义。
3.2 用 interface 继承组织复杂的 hook 上下文
热词里还有“typescript interface 怎么继承”,这在 Mods 场景里非常实用。因为不同的 hook 共享很多公共字段,比如会话 ID、工作目录、配置对象,但各自又有专属字段。用 interface 继承来组织,代码会非常干净:
interface BaseHookContext { sessionId: string; cwd: string; config: ModsConfig; } interface ToolCallContext extends BaseHookContext { toolName: string; args: Record<string, unknown>; } interface RenderContext extends BaseHookContext { output: string; style: OutputStyle; }这样组织的好处是,公共逻辑可以写成接受BaseHookContext的函数,工具调用和渲染相关的 hook 都能复用。我在实际项目里把日志、配置读取、路径解析这些公共能力抽成基于BaseHookContext的工具函数,省了大量重复代码。类型继承不只是语法糖,它是你组织扩展代码结构的骨架。
3.3 类型收窄在 hook 链里的实际价值
Mods 的 hook 链里,context 的类型可能是联合类型,因为同一个 hook 位置可能被不同类型的调用触发。这时候 TypeScript 的类型收窄(narrowing)就派上用场了。举个我实际遇到的场景:一个“工具调用前”的 hook,既可能被文件操作触发,也可能被命令执行触发,两者的 args 结构完全不同。如果不做收窄,你访问args.command时编辑器会报错,因为文件操作的 args 里没有这个字段。
function handleToolCall(ctx: ToolCallContext) { if (ctx.toolName === "runCommand") { // 这里 TypeScript 知道 args 里有 command 字段 const cmd = (ctx.args as { command: string }).command; if (cmd.includes("rm -rf")) { throw new Error("危险命令已拦截"); } } }这段代码里用类型断言做了收窄,更优雅的写法是用类型守卫函数。不管哪种方式,核心思路都是先判断工具类型,再访问对应字段。这个习惯能帮你避开大量运行时的undefined错误,而这些错误在 hook 里一旦发生,往往会导致整个会话流程中断,排查起来很烦。
4. 给 Claude 加工具:从“能调用”到“调得对”
4.1 自定义工具的本质是注册一个可被 AI 调用的函数
给 Claude 加工具,听起来很玄,拆开看其实就三步:定义工具的描述(告诉 AI 这个工具是干什么的、需要什么参数)、实现工具的逻辑(真正干活的函数)、把工具注册到 Mods 里。AI 在需要的时候,会根据描述决定要不要调用你的工具,然后把参数传进来。
这里最关键的是工具描述的质量。AI 判断是否调用某个工具,完全依赖你写的描述。描述写得含糊,AI 要么不调用,要么传错参数。我踩过的坑是,早期写描述时只写了“处理文件”,结果 AI 在需要读文件时调用了它,在需要写文件时也调用了它,因为描述没区分读写。后来我把描述改成“读取指定路径的文本文件内容,仅用于读取,不修改文件”,调用准确率立刻上来了。
4.2 参数 schema 的设计决定了调用准确率
工具的参数字段,建议用 JSON Schema 或等价的类型描述来约束。字段名要语义清晰,能枚举的就枚举,能给示例的给示例。下面是我常用的一个工具定义模板:
const readConfigTool = { name: "read_project_config", description: "读取项目根目录下的配置文件内容,用于获取构建、测试相关配置", parameters: { type: "object", properties: { fileName: { type: "string", description: "配置文件名,例如 tsconfig.json、package.json", enum: ["tsconfig.json", "package.json", ".eslintrc.json"], }, }, required: ["fileName"], }, handler: async (args: { fileName: string }) => { const path = join(process.cwd(), args.fileName); return await readFile(path, "utf-8"); }, };注意enum的用法。把可选值限定住,AI 就不会瞎编一个不存在的文件名。这个技巧在参数取值有限时特别管用,能显著降低调用失败率。另外handler里我用了process.cwd()而不是硬编码路径,这样工具在不同项目里都能用。
4.3 工具执行失败时怎么给 AI 有用的反馈
工具执行失败是常态,关键是失败信息怎么返回。如果你直接抛一个Error("failed"),AI 拿到这个信息基本没法自我纠正。正确的做法是返回结构化的错误信息,告诉 AI 哪里错了、可能的原因是什么、建议怎么调整。
我一般会把错误分成几类:参数错误(告诉 AI 哪个参数不合法)、环境错误(告诉 AI 缺什么依赖)、业务错误(告诉 AI 逻辑上为什么不成立)。这样 AI 在下一轮就有机会自己修正参数或换一种方式。实测下来,结构化错误信息能让 AI 的自愈率提升不少,尤其是参数类错误,很多时候 AI 看到具体哪个字段不对,下一轮就改对了。
5. 在终端画界面:让输出从“能看”到“好看”
5.1 终端渲染 hook 能控制什么
终端界面这块,是 Mods 里我觉得最有意思的部分。传统终端输出就是一行行纯文本,而通过渲染 hook,你可以控制颜色、缩进、对齐、甚至画简单的表格和进度条。热词里“html+css+js 基础语法”虽然是前端的东西,但终端渲染的思路和它有点像——你也是在“布局”,只不过画布变成了字符网格。
渲染 hook 能拿到当前要输出的内容,你可以对它做加工再返回。比如把一段 JSON 格式化成带颜色的树状结构,或者把工具执行结果包装成一个带边框的卡片。这里要注意的是,终端宽度是不固定的,用户可能把窗口拉得很宽或很窄,所以你的布局逻辑要能适应不同宽度,不能写死列数。
5.2 用 ANSI 转义序列做颜色和样式
终端里的颜色和样式,底层靠的是 ANSI 转义序列。虽然有很多库帮你封装,但理解原理能让你在库不满足需求时自己动手。一个转义序列大概长这样:\x1b[32m表示绿色,\x1b[0m表示重置。下面是我常用的几个:
const colors = { green: (s: string) => `\x1b[32m${s}\x1b[0m`, red: (s: string) => `\x1b[31m${s}\x1b[0m`, yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, dim: (s: string) => `\x1b[2m${s}\x1b[0m`, };用的时候直接colors.green("成功")就行。这里有个坑:如果你在字符串里嵌套了多种样式,重置符的位置要小心,否则会出现“后面的文字全变成绿色”这种串色问题。我的经验是,每个样式函数都自己负责开头和结尾的重置,不要依赖外层的重置,这样嵌套也不会乱。
5.3 画一个带边框的结果卡片
下面这个例子是我实际用过的,把工具执行结果渲染成一个带边框的卡片,宽度自适应终端:
function renderCard(title: string, body: string): string { const width = Math.min(process.stdout.columns || 80, 100); const innerWidth = width - 4; const top = "┌" + "─".repeat(innerWidth) + "┐"; const bottom = "└" + "─".repeat(innerWidth) + "┘"; const titleLine = "│ " + title.padEnd(innerWidth - 1) + "│"; const bodyLines = body .split("\n") .map((line) => "│ " + line.slice(0, innerWidth - 1).padEnd(innerWidth - 1) + "│"); return [top, titleLine, bottom, ...bodyLines, bottom].join("\n"); }这段代码里process.stdout.columns拿到终端宽度,Math.min限制最大宽度避免太宽不好看。padEnd负责补齐空格让边框对齐。实测下来,这种卡片式输出在展示工具结果时比裸文本清晰很多,尤其是结果里有多个字段的时候。
注意:中文字符在终端里占两个字符宽度,但
padEnd是按字符数算的,所以纯中文内容用padEnd对齐会错位。解决办法是用一个计算显示宽度的函数,把中文按 2 算。这个坑我踩过,边框歪得很难看。
6. 踩坑实录:hook 不生效、类型报错、界面串色的完整排查链路
6.1 hook 注册了但完全没触发
这是我最开始遇到的最打击人的问题:代码写好了,注册也写了,跑起来一点反应都没有。排查这个问题的链路我总结成三步。
第一步,确认 hook 文件真的被加载了。Mods 加载 hook 通常有约定的目录或配置项,如果文件放错位置,或者配置里没引用,它根本不会被读。我在文件顶部加了一行console.log("hook loaded"),跑起来发现这行都没打印,说明文件压根没被加载,问题出在注册环节而不是 hook 逻辑。
第二步,确认 hook 的导出方式对。有的机制要求默认导出,有的要求具名导出,导出方式不对,加载器拿不到你的函数。我改成默认导出后,hook loaded打印出来了,说明加载没问题了。
第三步,确认 hook 的时机匹配。文件加载了、函数也执行了,但业务逻辑没生效,那多半是挂错了时机。比如我一开始把拦截逻辑挂在“工具调用后”,命令都执行完了才拦截,当然没用。改到“工具调用前”就正常了。
这个三步链路——加载、导出、时机——基本能覆盖 90% 的“hook 不生效”问题。按顺序排查,比盲目改代码高效得多。
6.2 TypeScript 类型报错但运行时正常
有段时间我遇到一种情况:编辑器里一片红,类型报错,但代码跑起来完全正常。这种“类型和运行时不一致”的问题,根源通常是声明文件和实际实现不同步,或者你用的类型和 SDK 期望的类型对不上。
我的处理办法是,先看报错的具体类型是什么,然后跳到声明文件里核对。有一次是 SDK 的某个 hook 类型更新了,参数从位置参数改成了对象参数,我的代码还是老写法,类型报错但运行时因为 JS 的宽松性居然还能跑。这种问题最危险,因为它随时可能在下一次更新后彻底崩掉。类型报错不要忽略,它往往是在提醒你 API 变了。
6.3 终端界面在不同环境下串色、错位
界面这块的坑主要集中在环境差异上。同一个渲染逻辑,在我本地终端好看得很,换到 CI 环境或者别人的终端就串色、错位。原因有几个:一是不同终端对 ANSI 的支持程度不一样,有的不支持真彩色;二是终端宽度获取不到时process.stdout.columns是undefined,我的代码没做兜底;三是前面提到的中文宽度问题。
针对这些,我的做法是:颜色只用最基础的 8 色,不用真彩色;宽度获取加兜底默认 80;中文宽度单独算。另外,如果检测到输出被重定向到文件(非 TTY),就干脆不加颜色,避免日志文件里全是转义字符。这个判断可以用process.stdout.isTTY来做。
7. 把 Mods 用顺手的几个实战心得
7.1 从“只读”hook 开始,别一上来就改写
新手最容易犯的错,是一上来就写会改写上下文、拦截流程的 hook。这类 hook 一旦逻辑有问题,整个会话就乱了,而且很难判断是 AI 本身的问题还是你 hook 的问题。我的建议是,先从只读的 hook 开始,比如只打印日志、只记录信息,确认时机对、加载对、数据拿得到,再逐步加上改写逻辑。这样每一步都有反馈,出问题也好定位。
7.2 把公共逻辑抽成不依赖 hook 的纯函数
hook 里的逻辑最好尽量薄,真正的业务逻辑抽成纯函数放在单独的文件里。这样做的好处是,纯函数可以单独写单元测试,不用启动整个 Claude Code 环境。我现在的项目里,hook 文件基本就是“拿 context、调纯函数、返回结果”三行,所有复杂逻辑都在纯函数里,测试覆盖率能上去,调试也方便。
7.3 用环境变量控制 hook 的开关和详细程度
开发阶段我习惯用环境变量控制 hook 的行为,比如MODS_DEBUG=1时打印详细日志,MODS_DISABLE_INTERCEPT=1时临时关掉拦截逻辑。这样在排查“到底是 hook 的问题还是 AI 的问题”时,可以快速切换,不用改代码重新加载。这个习惯帮我省了很多来回折腾的时间。
7.4 版本升级后先跑一遍回归
Claude Code 和 Mods 的 SDK 都在迭代,类型和 hook 时机偶尔会调整。我养成的习惯是,每次升级后先跑一遍我那几个核心 hook,确认日志正常、拦截正常、渲染正常。有一次升级后某个 hook 的 context 字段改名了,因为我有回归习惯,第一时间就发现了,没等到线上出问题。扩展机制越灵活,越要盯着版本变化。
7.5 别把 hook 写成“万能胶”
最后说一个心态上的坑。Mods 的 hook 能力很强,容易让人产生“什么都能往里塞”的冲动。但我实测下来,hook 里做太多事会导致两个问题:一是性能,每个 hook 都在关键路径上,逻辑重了会拖慢整个会话;二是可维护性,一堆逻辑缠在一起,过两个月自己都看不懂。我的原则是,hook 只做“时机相关”的事,业务逻辑该放哪放哪。比如拦截危险命令是时机相关的,适合放 hook;而命令的具体校验规则,应该抽成独立的校验模块,hook 只负责调用它。
这套东西用下来,我最大的体会是,Mods 的价值不在于它提供了多少现成功能,而在于它把“改造 AI 助手”的权力交回给了开发者。你对自己工程环境的理解,比任何通用助手都深,而 Mods 就是把你这份理解翻译成 AI 能用的工具和界面的那层胶水。写 hook 的过程,其实也是在梳理自己团队的工程规范——哪些操作危险、哪些信息该注入、哪些输出该美化,想清楚这些,代码自然就顺了。