
1. Claude Code Mods 到底是个什么东西第一次听到 Claude Code Mods 这个词很多人会下意识以为是某种插件市场或者第三方魔改版本。其实不是。Claude Code 本身是 Anthropic 推出的一个跑在终端里的编程助手它能在你的项目目录下读写文件、执行命令、跑测试、改代码。而所谓 Mods指的是围绕它构建的一套扩展机制——你可以给它加自定义工具、改它的交互界面、甚至把终端变成一个带面板和快捷键的类 IDE 环境。说白了原生的 Claude Code 已经能干活了但它默认的交互方式比较朴素你在终端里打字它在终端里回你中间穿插一些工具调用。Mods 要解决的核心问题是——当你想让它接入自己的内部系统、想让它按你的工作流走、想让它在一个更顺手的界面里跑起来时原生能力不够用了。这时候就需要 Mods 这一层。这篇文章适合三类人看一是已经在用 Claude Code、但觉得差点意思的开发者二是想把自己团队的工具链接进 AI 助手的技术负责人三是对终端 UI 和 JS/TS 工具链感兴趣、想看看别人怎么在终端里画界面的工程师。我会从设计思路讲到具体实现把工具注册、界面渲染、参数传递这些环节拆开说清楚尽量让你看完能自己动手做一个。需要提前说明的是下面涉及的具体 API 名称和配置字段部分是基于 Claude Code 公开的扩展模式和常见终端工具链实践做的合理推演。如果你用的是特定版本字段名可能有出入但整体思路是通用的。2. 为什么要在 Claude Code 上做扩展2.1 原生能力的边界在哪里Claude Code 原生提供的能力大致是这几类文件读写、shell 命令执行、代码搜索、以及基于这些的组合操作。它在通用编程任务上表现很好但一旦你的需求带上强烈的个性化色彩就会碰到天花板。举个例子。你们团队有一套内部的部署脚本参数很多还依赖特定的环境变量。你当然可以让 Claude Code 直接执行 shell 命令但每次都要把参数拼对、把环境变量设好很容易出错。更好的做法是把它封装成一个工具让 Claude Code 知道有一个叫 deploy 的工具它接受 service 和 env 两个参数然后它就能自己决定什么时候调用、传什么参数。再比如界面。原生 Claude Code 的输出是流式的文本工具调用以折叠块的形式展示。如果你想要一个左侧是文件树、右侧是对话、底部是状态栏的布局原生做不到。这就需要用终端 UI 库自己画。2.2 Mods 解决的三个核心痛点我把实际使用中遇到的痛点归成三类这也是 Mods 存在的意义。第一类是工具接入。你的内部系统、私有 API、特定领域的计算逻辑原生 Claude Code 一概不认识。通过 Mods 注册自定义工具等于给它装上了外挂知识和外挂手脚。第二类是交互效率。纯文本对话在复杂任务里效率不高。你想快速切换文件、想看到实时的任务进度、想用快捷键触发常用操作这些都需要界面层的支持。第三类是流程固化。团队里每个人用 AI 的方式不一样导致结果不稳定。通过 Mods 把标准流程比如改代码前先跑 lint、改完必须跑测试固化进工具和界面能大幅降低人为差异。2.3 技术选型为什么是 JS/TSClaude Code 的扩展生态里JS/TS 是主力语言。原因很实际终端 UI 库比如 Ink、Blessed在 Node 生态里最成熟工具定义和参数校验用 TypeScript 的类型系统写起来最舒服而且前端开发者上手成本低不需要额外学一门语言。TypeScript 在这里的价值尤其明显。定义一个工具时你需要描述它的输入参数结构。用 TS 的 interface 写出来既能做编译期检查又能直接生成运行时的校验 schema一举两得。这也是为什么热词里 ts 相关的搜索那么多——大家在做扩展时确实绕不开它。3. 核心机制拆解工具是怎么被注册和调用的3.1 工具定义的基本结构一个 Claude Code 工具本质上就是一份说明书加一个执行函数。说明书告诉模型这个工具叫什么、干什么用、需要什么参数执行函数负责真正干活。用 TypeScript 写出来大概长这样interface ToolDefinitionTInput { name: string; description: string; inputSchema: { type: object; properties: Recordstring, unknown; required: string[]; }; execute: (input: TInput) PromiseToolResult; }这里有几个细节值得说。description不是写给人看的注释而是写给模型看的提示词。它直接决定了模型在什么场景下会想到调用这个工具。写得太笼统模型该用的时候想不起来写得太啰嗦又会占用宝贵的上下文。inputSchema用的是 JSON Schema 格式。为什么不用 TS 类型直接推导因为工具定义最终要序列化传给模型JSON Schema 是通用格式。实践中常见的做法是用 Zod 这类库先写校验逻辑再转成 JSON Schema这样运行时校验和给模型的说明就统一了。3.2 参数校验为什么不能省我见过不少人图省事工具执行函数里直接拿参数就用不做校验。这在 demo 阶段没问题一旦模型传了个意料之外的参数整个流程就崩了。模型传参出错的情况比想象中多。它可能把数字传成字符串可能漏掉必填字段可能在枚举值里选了个不存在的选项。如果你在 execute 函数开头做了严格校验就能在出错时返回一个清晰的错误信息模型看到后会自动重试或调整。这比直接抛异常、让整个会话中断要好得多。import { z } from zod; const DeployInput z.object({ service: z.string().min(1), env: z.enum([dev, staging, prod]), dryRun: z.boolean().default(true), }); // 校验失败时返回结构化错误而不是抛异常 const parsed DeployInput.safeParse(input); if (!parsed.success) { return { isError: true, content: 参数校验失败: ${parsed.error.message}, }; }注意dryRun默认值设成true这个细节。部署这种危险操作默认应该是演练而不是真干。模型如果没明确说要真部署就走演练流程。这是把安全默认值写进工具设计里的典型做法。3.3 工具调用的完整生命周期从模型决定调用工具到结果返回给模型中间经历了好几个环节。理解这个链路排查问题时才有方向。第一步模型在生成回复时输出一个工具调用请求包含工具名和参数。第二步运行时层拦截这个请求找到对应的工具定义。第三步用 inputSchema 校验参数。第四步执行 execute 函数。第五步把返回值包装成模型能理解的消息格式。第六步把消息追加到对话历史触发模型继续生成。任何一步出问题表现都是工具没反应或结果不对。所以排查时要从头到尾捋一遍模型到底有没有发起调用参数对不对执行函数有没有被触发返回值格式对不对提示调试工具调用时最有效的手段是在 execute 函数入口和出口各打一条日志。如果入口日志没出现说明问题在模型没调用或运行时没匹配上如果入口有、出口没有说明执行函数内部卡住或抛错了。4. 在终端里画界面从文本流到结构化 UI4.1 终端 UI 的基本原理终端本质上是一个字符网格你往里面写字符它显示出来。所谓画界面就是精确控制往哪个位置写什么字符。ANSI 转义序列是这套控制的基础比如\x1b[2J清屏、\x1b[H把光标移到左上角、\x1b[31m把文字变红。手写这些转义序列很痛苦所以有了封装库。Node 生态里主流的是 Ink 和 Blessed。Ink 用 React 的组件模型来写终端界面对前端开发者最友好Blessed 更底层控制力更强但写起来啰嗦。做 Claude Code 的界面扩展Ink 是更常见的选择。用 Ink 写一个最简单的布局大概是这样import React from react; import { Box, Text } from ink; const App () ( Box flexDirectioncolumn height100% Box borderStyleround paddingX{1} Text bold colorcyanClaude Code Mods/Text /Box Box flexGrow{1} borderStylesingle Text对话区域/Text /Box Box borderStylesingle paddingX{1} Text dimColor状态栏/Text /Box /Box );这段代码渲染出来就是一个上中下三段的布局。flexGrow{1}让中间区域占满剩余空间这是 Flexbox 的思路Ink 把它搬到了终端里。4.2 布局设计的几个坑终端界面和网页界面差别很大有几个坑必须提前知道。尺寸是动态的。用户随时可能调整终端窗口大小你的布局必须能响应。Ink 提供了useStdout钩子可以拿到当前的列数和行数。所有依赖尺寸的计算都要放在这个钩子里尺寸一变就重新渲染。字符宽度不统一。中文、emoji 占两个字符宽度英文占一个。如果你按字符数算宽度中文就会错位。处理办法是用string-width这类库来算真实显示宽度别自己数。颜色支持参差不齐。有的终端支持真彩色有的只支持 256 色有的甚至只有 16 色。稳妥的做法是用 chalk 这类库它会自动降级。别硬编码 RGB 值。滚动是个难题。终端没有原生的滚动容器你得自己实现。常见做法是维护一个可视区域只渲染落在区域内的内容超出部分裁掉。内容多的时候还要考虑虚拟滚动不然性能会崩。4.3 把工具调用可视化界面做出来之后最有价值的改进是把工具调用过程可视化。原生 Claude Code 里工具调用是一个折叠的块你得展开才能看到细节。在自定义界面里你可以做得更直观。我的做法是给每个工具调用分配一个状态卡片显示工具名、参数摘要、执行状态等待中/执行中/成功/失败、耗时。执行中的卡片有个转圈的动画完成后变成对勾或叉号。这样一眼就能看出当前在干什么、哪一步卡住了。const ToolCallCard ({ name, status, duration }) { const icon { pending: ○, running: ◐, success: ●, error: ✕, }[status]; return ( Box Text color{status error ? red : green}{icon}/Text Text {name}/Text {duration Text dimColor ({duration}ms)/Text} /Box ); };这种可视化带来的效率提升是实打实的。以前要盯着文本流猜它在干嘛现在扫一眼状态栏就清楚了。5. 从零搭一个 Mod完整实操流程5.1 环境准备与项目初始化先把基础环境弄好。Node 版本建议 18 以上太老的版本对 ESM 和顶层 await 支持不好。包管理器用 npm、pnpm、yarn 都行我个人习惯 pnpm装依赖快、磁盘占用小。mkdir claude-code-mod-demo cd claude-code-mod-demo pnpm init pnpm add ink react zod chalk string-width pnpm add -D typescript types/react types/node tsxtsx这个工具值得单独说一句。它让你能直接运行 .ts 文件不用先编译。开发阶段用它跑脚本改完代码直接重跑省去了编译等待。生产环境再走正式的构建流程。初始化 TypeScript 配置{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: bundler, jsx: react-jsx, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: dist }, include: [src] }jsx设成react-jsx是关键不然 Ink 的组件写法会报错。strict打开虽然写起来麻烦点但能提前发现很多类型问题。5.2 定义你的第一个工具我们来做一个实用的工具查询项目里某个依赖的版本信息。这个需求很常见模型想知道某个库当前装的是什么版本原生能力做不到得靠工具。import { z } from zod; import { readFile } from node:fs/promises; import { join } from node:path; const GetDepVersionInput z.object({ packageName: z.string().describe(要查询的依赖包名), }); export const getDepVersionTool { name: get_dep_version, description: 查询当前项目中指定 npm 依赖的已安装版本。当需要确认某个库的实际版本号时使用。, inputSchema: { type: object, properties: { packageName: { type: string, description: 要查询的依赖包名例如 react, }, }, required: [packageName], }, async execute(input: unknown) { const parsed GetDepVersionInput.safeParse(input); if (!parsed.success) { return { isError: true, content: 参数错误: ${parsed.error.message}, }; } const { packageName } parsed.data; try { const pkgPath join(process.cwd(), node_modules, packageName, package.json); const raw await readFile(pkgPath, utf-8); const pkg JSON.parse(raw); return { content: ${packageName} 的已安装版本是 ${pkg.version}, }; } catch { return { isError: true, content: 未找到依赖 ${packageName}可能尚未安装, }; } }, };这个工具虽然简单但把几个关键点都覆盖了用 Zod 做校验、description 写清楚使用场景、错误处理返回结构化信息而不是抛异常。5.3 注册工具并接入运行时定义好工具之后要把它注册到运行时里。注册的方式取决于你用的具体扩展框架但核心逻辑是一样的维护一个工具名到工具定义的映射表运行时收到调用请求时查表。const tools new Mapstring, ToolDefinition(); export function registerTool(tool: ToolDefinition) { if (tools.has(tool.name)) { throw new Error(工具名冲突: ${tool.name}); } tools.set(tool.name, tool); } export async function dispatchToolCall(name: string, input: unknown) { const tool tools.get(name); if (!tool) { return { isError: true, content: 未知工具: ${name}, }; } return tool.execute(input); }工具名冲突这个检查别省。多人协作时两个人可能定义了同名工具不检查的话后注册的会静默覆盖前面的排查起来很头疼。5.4 参数传递中的类型陷阱热词里有个 ts jsonvalue 的搜索这其实点到了一个真实痛点工具参数在传输过程中会经历序列化和反序列化类型信息会丢失。模型传过来的参数运行时拿到的是unknown。你以为是数字实际可能是字符串你以为是数组实际可能是单个值。所以校验这一步绝对不能跳过而且校验要严格。还有一个容易忽略的点JSON 不支持undefined。如果你在参数里传了undefined序列化后这个字段会直接消失。所以可选参数要用null或者干脆不传别指望undefined能传过去。// 不推荐undefined 会在序列化时丢失 const input { name: foo, options: undefined }; // 推荐用 null 表示显式为空 const input { name: foo, options: null };5.5 界面与工具的联动工具和界面不是两套独立的东西它们要联动。工具执行时界面要能收到状态更新界面上的操作也要能触发工具调用。实现方式通常是事件总线。工具执行前后各发一个事件界面订阅这些事件来更新状态。type ToolEvent | { type: start; name: string; input: unknown } | { type: end; name: string; result: unknown; duration: number } | { type: error; name: string; error: string }; const listeners new Set(e: ToolEvent) void(); export function emitToolEvent(event: ToolEvent) { listeners.forEach((fn) fn(event)); } export function onToolEvent(fn: (e: ToolEvent) void) { listeners.add(fn); return () listeners.delete(fn); }界面组件在挂载时订阅事件卸载时取消订阅。这样工具层和界面层就解耦了各自独立演进。6. 常见问题与排查实录6.1 工具不生效的几种典型情况工具注册了但模型不调用这是最常见的问题。原因通常有三个。description 写得太模糊。模型判断要不要调用工具主要看 description。如果写的是处理数据模型根本不知道什么时候该用。要写清楚当需要 X 时使用。参数 schema 有问题。如果 schema 格式不对模型可能无法正确构造参数干脆就不调用了。用 JSON Schema 校验器检查一下你的 schema 是否合法。工具名和已有工具冲突。前面提到的冲突检查能帮你发现这个问题。6.2 界面渲染错乱的排查思路终端界面出问题表现五花八门文字重叠、边框错位、颜色不对。排查时按这个顺序来。先确认终端尺寸。用process.stdout.columns和process.stdout.rows打印出来看看是不是和你以为的不一样。有些终端在启动时尺寸上报不准要等 resize 事件。再检查字符宽度计算。中文和 emoji 是重灾区。用string-width算一下你渲染的字符串实际占多少列和你的布局预期对比。最后看颜色。如果颜色显示不对可能是终端不支持真彩色。用 chalk 的level属性看看当前支持到几级。现象可能原因排查方法文字重叠宽度计算错误用 string-width 核对边框错位终端尺寸变化未响应监听 resize 事件颜色异常终端色深不足检查 chalk.level内容闪烁全量重渲染改用增量更新滚动卡顿渲染内容过多实现虚拟滚动6.3 性能问题的处理经验终端界面做复杂了会卡这是必然的。几个实用的优化手段。减少重渲染。Ink 里用React.memo包住不常变的组件用useMemo缓存计算结果。别让整个界面因为一个小状态变化就全部重画。控制渲染频率。流式输出时模型可能每秒吐几十个字符。如果每个字符都触发重渲染界面会卡死。做法是攒一批再渲染比如每 50ms 更新一次。虚拟滚动。对话历史长了之后只渲染可视区域内的内容。这个前面提过是长列表的标配。提示开发阶段可以用console.log打点但注意 Ink 会接管 stdout直接 log 会破坏界面。用 Ink 提供的useStderr或者写到文件里。6.4 调试工具调用的实用技巧调试工具调用最有效的是把中间过程都记下来。我习惯在 dispatch 层加一个可选的日志开关打开后把每次调用的工具名、参数、返回值、耗时都写到文件里。async function dispatchWithLog(name: string, input: unknown) { const start Date.now(); const result await dispatchToolCall(name, input); if (process.env.MOD_DEBUG) { await appendFile( mod-debug.log, JSON.stringify({ name, input, result, duration: Date.now() - start, }) \n ); } return result; }有了这份日志出问题时直接翻记录比在终端里猜快得多。7. 扩展方向与个人实践体会工具和界面都跑通之后能扩展的方向其实很多。我列几个自己试过、觉得有价值的。工具组合。单个工具能力有限但把几个工具串起来就能完成复杂任务。比如查依赖版本加查更新日志加生成升级建议三个工具配合模型就能给出完整的升级方案。配置持久化。把常用的工具参数、界面布局偏好存到配置文件里下次启动直接加载。省去每次重新设置的麻烦。多会话管理。同时开多个会话每个会话独立的上下文和工具状态。这在并行处理多个任务时很有用。权限控制。危险工具比如部署、删除加一道确认或者限制在特定环境下才能调用。这是把安全边界写进工具设计里的做法。我在实际做这些扩展的过程中最大的体会是工具的描述比工具的实现更重要。实现写得再漂亮如果模型不知道什么时候该用这个工具就是废的。反过来一个实现很简单的工具只要描述写得精准模型用起来就很顺手。所以每次加新工具我都会花不少时间打磨 description甚至专门测试模型在不同场景下会不会正确调用。另一个体会是别过度设计界面。我一开始想做一个功能齐全的类 IDE结果做了两周发现大部分功能自己根本不用。后来砍到只剩对话区、工具状态栏、文件树三块反而更顺手。终端界面的优势是轻快别把它做成一个笨重的桌面应用。最后分享一个小技巧如果你在团队里推广这套东西先做一个最小可用的版本让同事用起来收集反馈再迭代。一上来就搞大而全往往没人愿意用。工具的价值在于被使用不在于功能多。