1. Claude Code Mods 到底是个什么东西
第一次听到“Claude Code Mods”这个词,很多人会以为是某个插件市场或者第三方魔改版本。其实不是。Claude Code 本身是 Anthropic 推出的一个跑在终端里的编程助手,你可以把它理解成一个住在命令行里的结对程序员——它能读你的项目文件、执行命令、改代码、跑测试。而所谓 Mods,指的是围绕 Claude Code 做的一层“扩展改造”:给它挂上自定义工具,或者用脚本在终端里画出更顺手的交互界面。
说白了,Claude Code 原生只带了一套基础能力:读写文件、执行 shell、搜索代码。但真实开发场景里,你需要它连数据库、调内部 API、查 Jira 工单、生成特定格式的报告。这些原生能力覆盖不到的地方,就是 Mods 的生存空间。它的核心逻辑是:Claude Code 暴露了一套工具注册机制和钩子(hooks),你通过 JS 或 TS 写一个模块,声明“我有一个新工具叫 xxx,输入参数是这些,执行逻辑是这些”,然后把它挂载到 Claude Code 的运行时里。挂载之后,Claude 在对话中就能像调用内置工具一样调用你的自定义工具。
终端界面这部分则更有意思。Claude Code 的交互默认是纯文本流,但通过 Mods 你可以用 ANSI 转义序列、boxen、ink 这类库,在终端里渲染出带边框的面板、进度条、表格甚至简易的 TUI(终端用户界面)。比如你想让 Claude 在跑一个长任务时显示实时进度,或者把多个工具的返回结果并排展示,这些都能通过 Mods 实现。
这套东西适合谁?三类人最值得看:一是每天泡在终端里的后端或 DevOps 工程师,你们本来就习惯命令行工作流,Claude Code Mods 能让 AI 助手真正融入你的终端环境;二是需要把 AI 能力嵌入内部工具链的团队,比如你们有自己的代码审查系统、部署平台,通过 Mods 可以把 Claude 接进去;三是对终端 UI 有执念的开发者,喜欢用 JS/TS 折腾各种交互效果的人。
我最初接触这个是因为团队里有个需求:每次让 Claude 改完代码后,自动跑一遍我们内部的 lint 规则并把结果以表格形式打出来。原生 Claude Code 做不到这个,但用 Mods 写一个自定义工具加一个终端渲染层,两天就搞定了。下面我把这套东西拆开讲清楚。
2. 核心机制拆解:工具注册与终端渲染是怎么跑通的
2.1 Claude Code 的工具调用协议长什么样
要理解 Mods,先得知道 Claude Code 怎么调用工具。它内部维护一个工具注册表,每个工具是一个对象,包含 name、description、input_schema 和 handler。当 Claude 在对话中决定调用某个工具时,它会输出一个结构化的 tool_use 块,运行时解析这个块,找到对应的 handler,把参数传进去,拿到返回值后再塞回对话上下文。
原生工具比如 Read、Write、Bash 都是这个机制。Mods 做的事情就是往这个注册表里追加新条目。你写的 JS/TS 模块需要导出一个符合规范的工厂函数,运行时加载后会调用这个函数,把工具定义注册进去。
这里有个关键点:input_schema 用的是 JSON Schema 格式。这意味着你需要把工具接受的参数用 JSON Schema 描述清楚,包括类型、是否必填、默认值、枚举范围等。Claude 会根据这个 schema 来生成调用参数,所以 schema 写得越精确,Claude 调用时出错概率越低。我见过有人偷懒只写个{ type: "object" },结果 Claude 传参乱七八糟,handler 里全是防御性判断,非常痛苦。
// 一个最小化的工具定义示例 module.exports = { name: "query_internal_api", description: "查询内部工单系统的接口,根据工单号返回详情", input_schema: { type: "object", properties: { ticket_id: { type: "string", description: "工单编号,格式为 TICKET-数字" }, fields: { type: "array", items: { type: "string" }, description: "需要返回的字段列表,不传则返回全部" } }, required: ["ticket_id"] }, handler: async (params) => { const resp = await fetch(`https://internal.api/tickets/${params.ticket_id}`); const data = await resp.json(); if (params.fields) { return Object.fromEntries( Object.entries(data).filter(([k]) => params.fields.includes(k)) ); } return data; } };上面这段代码就是最基础的工具模块。注意 handler 是 async 的,因为大部分工具调用都涉及 IO。返回值可以是对象、字符串或数组,Claude Code 运行时会把它序列化后放回上下文。
2.2 终端界面渲染的几种技术路线
终端里画界面,本质上是在一个字符网格上做文章。Claude Code Mods 里常用的方案有三类:
第一类是纯 ANSI 转义序列。这是最底层的方式,通过\x1b[开头的控制码来移动光标、设置颜色、清屏。优点是零依赖,任何终端都支持;缺点是写起来极其繁琐,画一个带边框的表格要手动计算每个字符的位置。
第二类是 boxen + chalk 组合。boxen 负责画边框和布局,chalk 负责颜色。这两个库在 Node 生态里非常成熟,API 也简单。比如boxen('内容', { padding: 1, borderStyle: 'round' })就能生成一个圆角边框的盒子。适合做静态的信息展示面板。
第三类是 ink。ink 把 React 的组件模型搬到了终端里,你可以用 JSX 写终端界面,支持 Flexbox 布局、状态更新、焦点管理。如果你要做交互式的 TUI,比如带滚动列表、输入框、按钮的界面,ink 是目前最顺手的选择。缺点是包体积大,启动慢,适合常驻进程。
选哪条路线取决于你的场景。如果只是想在工具返回结果时加个边框和颜色,boxen + chalk 足够了。如果要做一个持续运行的监控面板,ink 更合适。纯 ANSI 只推荐在极端轻量场景下用,比如你不想引入任何依赖。
2.3 钩子机制:在工具调用前后插入逻辑
除了注册新工具,Mods 还能通过钩子机制在现有流程里插入逻辑。Claude Code 暴露了几个关键钩子点:工具调用前(pre-tool-use)、工具调用后(post-tool-use)、会话开始(session-start)、会话结束(session-end)。
pre-tool-use 钩子的典型用途是权限校验和参数改写。比如你希望 Claude 在执行 Bash 命令前先检查命令是否在白名单里,不在就拦截并返回提示。post-tool-use 则适合做结果后处理,比如把工具返回的 JSON 自动格式化成表格,或者记录调用日志。
// pre-tool-use 钩子示例:拦截危险命令 module.exports = { hook: "pre-tool-use", handler: async (context) => { if (context.tool_name === "Bash") { const cmd = context.tool_input.command; const blocked = ["rm -rf", "DROP TABLE", "shutdown"]; for (const pattern of blocked) { if (cmd.includes(pattern)) { return { action: "block", message: `命令包含禁止模式 "${pattern}",已拦截` }; } } } return { action: "allow" }; } };钩子的返回值里 action 可以是 allow、block 或 modify。modify 允许你改写工具输入,这个在需要自动补全参数时很有用。
3. 从零搭一个 Mods 项目:完整实操流程
3.1 环境准备与项目初始化
先确认你的 Node 版本。Claude Code Mods 依赖 Node 18 以上,因为用到了原生 fetch 和顶层 await。用node -v检查,低于 18 的话建议用 nvm 切一个 LTS 版本。
node -v # v20.11.0 以上即可 mkdir claude-mods-demo && cd claude-mods-demo npm init -y npm install boxen chalk如果你打算用 ink 做界面,再装npm install ink react。TypeScript 用户额外装npm install -D typescript @types/node tsx,然后npx tsc --init生成配置。
项目目录结构建议这样组织:
claude-mods-demo/ ├── package.json ├── tsconfig.json ├── src/ │ ├── tools/ # 自定义工具模块 │ │ └── query-api.ts │ ├── hooks/ # 钩子模块 │ │ └── pre-bash.ts │ └── ui/ # 终端界面组件 │ └── table.ts └── mods.config.json # Mods 加载配置mods.config.json 是告诉 Claude Code 去哪里加载你的模块:
{ "tools": ["./src/tools/query-api.ts"], "hooks": { "pre-tool-use": ["./src/hooks/pre-bash.ts"] } }3.2 写第一个自定义工具:内部 API 查询器
假设你们公司有个内部工单系统,你想让 Claude 在对话中直接查工单。先定义工具模块,用 TypeScript 写类型更安全:
// src/tools/query-api.ts import type { ToolModule } from "@anthropic/claude-code-mods"; interface TicketParams { ticket_id: string; fields?: string[]; } const tool: ToolModule<TicketParams> = { name: "query_ticket", description: "根据工单号查询内部工单系统,返回工单详情", input_schema: { type: "object", properties: { ticket_id: { type: "string", description: "工单编号,例如 TICKET-12345" }, fields: { type: "array", items: { type: "string" }, description: "指定返回字段,不传返回全部" } }, required: ["ticket_id"] }, handler: async (params) => { const url = new URL(`https://internal.ticket.api/v1/tickets/${params.ticket_id}`); if (params.fields?.length) { url.searchParams.set("fields", params.fields.join(",")); } const resp = await fetch(url.toString(), { headers: { Authorization: `Bearer ${process.env.TICKET_API_TOKEN}` } }); if (!resp.ok) { throw new Error(`查询失败: ${resp.status} ${resp.statusText}`); } return await resp.json(); } }; export default tool;这里有几个实操细节值得说。第一,description 要写得像给同事解释一样清楚,Claude 靠这个判断什么时候该调用你的工具。第二,handler 里抛出的错误会被 Claude Code 捕获并作为工具结果返回,Claude 看到错误信息后会尝试修正或告知用户,所以错误信息要写人话。第三,敏感 token 走环境变量,不要硬编码。
3.3 用 boxen 和 chalk 渲染工具返回结果
工具返回的 JSON 直接打在终端里很难看。写一个后处理钩子,把结果格式化成表格:
// src/ui/table.ts import boxen from "boxen"; import chalk from "chalk"; export function renderTicketTable(ticket: Record<string, unknown>): string { const rows = Object.entries(ticket) .map(([key, value]) => { const label = chalk.cyan(key.padEnd(16)); const val = typeof value === "object" ? JSON.stringify(value) : String(value); return `${label} ${val}`; }) .join("\n"); return boxen(rows, { title: chalk.green("工单详情"), padding: 1, borderStyle: "round", borderColor: "gray" }); }然后在 post-tool-use 钩子里调用:
// src/hooks/post-ticket.ts import { renderTicketTable } from "../ui/table"; export default { hook: "post-tool-use", handler: async (context: any) => { if (context.tool_name === "query_ticket" && context.tool_result) { return { action: "modify", result: renderTicketTable(context.tool_result) }; } return { action: "allow" }; } };这样 Claude 查完工单后,终端里显示的就是一个带圆角边框、字段名高亮的面板,比原始 JSON 可读性高很多。
3.4 参数计算与选择过程:什么时候该用 ink
boxen 适合静态展示,但如果你要做一个实时刷新的监控面板,比如持续显示 Claude 当前正在执行的任务队列,boxen 就不够用了。这时候上 ink。
ink 的核心是 React 组件,用useState和useEffect管理状态,用<Box>和<Text>做布局。下面是一个简易的任务队列面板:
// src/ui/task-queue.tsx import React, { useState, useEffect } from "react"; import { Box, Text, render } from "ink"; const TaskQueue = () => { const [tasks, setTasks] = useState<string[]>([]); useEffect(() => { const timer = setInterval(() => { // 从某个共享状态读取当前任务队列 setTasks(globalThis.__claudeTaskQueue ?? []); }, 500); return () => clearInterval(timer); }, []); return ( <Box flexDirection="column" borderStyle="round" paddingX={1}> <Text bold color="green">任务队列</Text> {tasks.length === 0 ? ( <Text dimColor>暂无任务</Text> ) : ( tasks.map((t, i) => ( <Text key={i}>{`${i + 1}. ${t}`}</Text> )) )} </Box> ); }; export function mountTaskQueue() { render(<TaskQueue />); }选 ink 的判断标准很简单:如果你的界面需要根据时间或事件持续更新,且更新频率高于每秒一次,用 ink;如果只是工具调用后展示一次结果,boxen 足够。ink 的代价是启动时多几百毫秒,内存占用也更高,别为了炫技在轻量场景硬上。
4. 踩坑实录:那些文档里不会写的问题
4.1 工具注册失败的五种常见原因
我前后搭过三个 Mods 项目,工具注册失败遇到过不下十次。整理成速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Claude 完全不调用你的工具 | description 太模糊或与内置工具重叠 | 把 description 改具体,加“当用户提到 xxx 时使用” |
| 调用时报 schema 校验错误 | input_schema 缺少 required 或类型写错 | 用 JSON Schema 校验器单独验证 schema |
| handler 不执行 | 模块导出方式不对 | 确认是 default export 且符合 ToolModule 类型 |
| 参数传进来是 undefined | schema 里属性名和 handler 里解构名不一致 | 打印 params 看实际传入结构 |
| 工具调用后 Claude 不继续对话 | handler 抛了未捕获异常 | 在 handler 里 try-catch,返回结构化错误 |
最隐蔽的是第一条。Claude 判断是否调用工具,主要看 description 和当前对话的相关性。如果你写“查询数据”,它可能觉得内置的 Read 也能干这事,就不调你的。改成“查询内部工单系统的工单详情,仅当用户提供 TICKET- 开头的编号时使用”,命中率立刻上去。
4.2 终端渲染的兼容性陷阱
ANSI 颜色和边框在不同终端里表现差异很大。我在 iTerm2 里调好的圆角边框,到 Windows Terminal 里变成了直角,到某些 SSH 客户端里甚至乱码。几个应对策略:
- 颜色用 chalk 的 level 检测,
chalk.level为 0 时自动降级为无颜色输出 - 边框字符优先用 ASCII 的
+ - |,除非确认目标终端支持 Unicode - 宽度不要写死,用
process.stdout.columns动态计算,窗口 resize 时重新渲染 - 避免使用 256 色和真彩色,除非你确定终端支持
还有一个坑:boxen 默认会做文本换行,如果你的内容里有长 URL 或 base64 字符串,它会在中间断开,看起来像乱码。解决办法是设置width参数或对长字符串做截断处理。
4.3 性能问题:钩子里的同步阻塞
pre-tool-use 钩子是在工具调用前同步执行的。如果你在钩子里做了耗时的网络请求,整个 Claude Code 的响应会卡住。我犯过一次错:在 pre-tool-use 里调了一个内部权限校验接口,那个接口平均响应 800ms,结果每次工具调用都多等将近一秒,体验极差。
正确做法是:钩子里只做轻量判断,需要远程校验的走缓存或异步预取。如果确实需要同步等待,把超时设短(比如 200ms),超时后默认放行而不是阻塞。
const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 200); try { const resp = await fetch(checkUrl, { signal: controller.signal }); // 处理结果 } catch { // 超时或失败,默认放行 } finally { clearTimeout(timeout); }4.4 调试技巧:把 Mods 的日志单独输出
Claude Code 本身的输出和你的 Mods 输出混在一起,调试时很难分辨。我的做法是在 Mods 里统一用一个 logger,把日志写到单独的文件:
import fs from "fs"; const logStream = fs.createWriteStream("/tmp/claude-mods.log", { flags: "a" }); export function log(...args: unknown[]) { const line = `[${new Date().toISOString()}] ${args.map(String).join(" ")}\n`; logStream.write(line); }然后在另一个终端窗口tail -f /tmp/claude-mods.log,实时看 Mods 的执行情况。这个习惯帮我省了大量时间,尤其是排查钩子执行顺序问题时。
5. 进阶玩法:把 Mods 串成工作流
5.1 多工具协作:让 Claude 自己编排调用顺序
单个工具能力有限,真正的威力在于多个工具组合。比如你注册了三个工具:search_code(搜代码)、run_lint(跑 lint)、create_pr(提 PR)。Claude 在对话中可以根据你的指令自动编排:先搜相关代码,再跑 lint,最后提 PR。你不需要写编排逻辑,Claude 自己会规划。
但这里有个技巧:在工具 description 里写明依赖关系。比如create_pr的 description 里加一句“调用前应先确保 run_lint 已通过”。Claude 看到这个提示后,会更倾向于按正确顺序调用。
5.2 用钩子实现自动格式化与日志审计
post-tool-use 钩子除了格式化输出,还能做审计。每次工具调用后,把 tool_name、参数、结果摘要写进一个审计日志。这在团队协作场景下很有用,能追溯 Claude 到底改了什么。
export default { hook: "post-tool-use", handler: async (context: any) => { const entry = { time: new Date().toISOString(), tool: context.tool_name, input: JSON.stringify(context.tool_input).slice(0, 200), success: !context.error }; await appendAuditLog(entry); return { action: "allow" }; } };注意 input 要截断,否则大文件内容会把日志撑爆。
5.3 终端界面的状态共享方案
如果你用 ink 做常驻面板,需要和工具 handler 共享状态。最直接的方式是挂一个全局对象,handler 往里写,ink 组件定时读。但这种方式在并发工具调用时会有竞态问题。更稳妥的是用 Node 的 EventEmitter:
import { EventEmitter } from "events"; export const bus = new EventEmitter(); // handler 里 bus.emit("task-update", { id, status: "running" }); // ink 组件里 useEffect(() => { const handler = (data: any) => setTasks(prev => [...prev, data]); bus.on("task-update", handler); return () => { bus.off("task-update", handler); }; }, []);EventEmitter 是进程内的,不需要额外依赖,对于单进程的 Claude Code Mods 场景完全够用。
5.4 打包与分发:让队友一键安装
自己用没问题了,怎么让团队其他人也能用?最土的办法是把整个目录拷过去,但版本管理很麻烦。推荐做成 npm 包,私有 registry 或直接 git URL 安装都行。
package.json 里关键字段:
{ "name": "@yourorg/claude-mods", "version": "1.0.0", "main": "dist/index.js", "types": "dist/index.d.ts", "files": ["dist"], "scripts": { "build": "tsc", "prepublishOnly": "npm run build" } }队友安装后,在 mods.config.json 里引用包名而不是相对路径:
{ "tools": ["@yourorg/claude-mods/dist/tools/query-api.js"] }这样升级时只要npm update就行,不用手动同步文件。
6. 几个我实际用下来的体会
Mods 这套机制最舒服的地方是它没有侵入性。你不需要改 Claude Code 的源码,也不用等官方支持某个功能,自己写个模块挂上去就行。我团队里现在跑着七八个自定义工具,从查工单到触发部署流水线,基本覆盖了日常开发的高频操作。
但也要泼盆冷水:不是所有东西都适合做成 Mods。如果你的需求只是“让 Claude 读某个文件”,用内置的 Read 工具就够了,没必要自己写一个。Mods 的价值在于填补内置能力的空白,而不是重复造轮子。我见过有人写了个工具就为了格式化 JSON,结果 Claude 用内置 Bash 跑jq效果一样好。
终端界面这块,我的建议是克制。boxen 加个边框、chalk 上个色,已经能覆盖 80% 的展示需求。ink 虽然强大,但引入 React 运行时后启动明显变慢,除非你真的需要持续刷新的交互界面,否则别轻易上。我现在的做法是:工具结果用 boxen 渲染,只有那个任务队列面板用 ink,两者共存没问题。
最后说一个容易被忽略的点:Mods 的加载顺序。如果你有多个钩子注册在同一个钩子点上,它们的执行顺序取决于配置文件里的数组顺序。pre-tool-use 钩子如果有一个返回 block,后面的钩子就不会执行了。所以把权限校验类的钩子放在数组前面,日志记录类的放后面,这个顺序要心里有数。