Claude Code mod 入门
从空文件夹开始做出你的第一个 Claude Code mod,再看看这套 API 还能做些什么。
Claude Code 本身就允许你调整不少行为:设置、权限规则、斜杠命令、skill(技能)和状态栏。mod 能走得更远:它可以改写甚至替换 Claude Code 的行为,还能自己绘制界面。底层上,mod 就是 hook(钩子),随插件一起分发。每个 mod 都是一个很小的 JavaScript 或 TypeScript 模块,运行在你的会话里,能实时看到发生的每一个事件。
有了 mod,你就能把 Claude Code 改造成贴合自己工作方式的样子:加一个自己常看的读数面板,给那些让你不放心的命令加一道拦截,或者搭一个符合自己审阅习惯的改动查看视图。
本指南会从空文件夹开始做一个 mod:Token Weather,它把上下文窗口的实时「天气预报」画在输入提示上方,代码大约 80 行。然后带你浏览两个更大的 mod,Blast Radius 和 Replay Theater,看看这套 API 还能做什么。
FIG A Token Weather、Blast Radius 和 Replay Theater 在终端会话里依次出现

FIG B 暂停观察 Token Weather 横条随上下文窗口填充的变化:Clear 18%、Showers 67%、Storm 81%
需要 Claude Code 2.1.287 或更高版本。mod 默认开启,无需手动打开。API 可能在不同版本之间发生变化。Claude Code 每次加载 mod 时,都会把你这个版本的类型声明写进该 mod 的 .claude-plugin/types/ 文件夹,这些声明才是你这个版本的权威依据。
mod 是怎么工作的
mod 就是一个 Claude Code 插件,它的行为逻辑放在一个 JavaScript 或 TypeScript 模块里:
- 文件夹是普通插件,带一个
.claude-plugin/plugin.json清单文件。 hooks/hooks.json在modules下指定一个模块。- 该模块导出
register(on, options)。在里面用on(event, matcher?, hook)添加 hook。
每个 hook 的结构都一样:
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
// $ the mods API: ui, session, state, store, fs, process, clock, http, tool, command, model, ...
// e this event's input, as plain data
// next passes e to the other plugins and then to Claude Code's own behavior
return next(e);
});
hook 串成一条链,像中间件。你的 hook 跑完后,next(e) 把事件交给下一个插件,一路传到底,最后 Claude Code 照它原本的逻辑处理。一个 hook 能做三类事中的一种:
事件覆盖了工具调用、用户提交的 prompt、turn 的开始与结束、会话的开始与结束、斜杠命令,以及 ui.render——界面每一次绘制。模块跑在自己的沙箱里,既没有 DOM 也没有 Node,所以要碰到外面的东西都得经过 $。
跟 settings hook 有什么不同。 settings hook 每来一个事件就起一个 shell 命令,靠标准输入输出传 JSON。mod 只加载一次,然后一直留在会话里。它能保存状态、画出随事件变化的界面,还能反过来调用 Claude Code:打开一个面板、跑一个进程、注册一条斜杠命令,或者注册一个模型可调用的工具。
Claude Code 自己也在用。 Claude Code 有些功能本身就是用 mod 写的,包括对 AGENTS.md 的支持,以及对话旁边的 /diff 面板。源码带测试,放在公开仓库 anthropics/claude-code 的 mods/ 目录下,你可以去看官方团队是怎么写的。
做你的第一个 mod:Token Weather
每轮对话结束后,Token Weather 都会读一下上下文窗口被占用了多少,然后在输入框上方画一行:一个天气图标、占用百分比、已用 token 数(分母是窗口总量)、最近几轮的小图表,以及最后一轮新增了多少。
下面是真实会话里的效果。每轮读的文件越来越多,这条色带从 ☀ Clear 涨到 ☂ Showers,再到 ☇ Storm:
FIG C:Token Weather 在完整终端会话中跨三轮的变化:200k 窗口,先是 18%,然后 67%,最后 81%
捷径:让 Claude 来写
这六步可以跳过。Claude Code 会写 mod,你只要把想要的说清楚,剩下的交给它。用 claude 开一个会话,把下面这段 prompt 粘进去:
给我做一个叫 token-weather 的 Claude Code mod:实时预测我的上下文窗口,显示在提示框上方那条状态栏里。
它要在一行里显示这些内容:
- 一个天气图标加一个词,表示上下文窗口的占用程度:不到 25% 是 ☀ Clear(黄色),25–49% 是 ☁ Cloudy(青色),50–74% 是 ☂ Showers(蓝色),75–89% 是 ☇ Storm(品红),90% 及以上是 ↯ Compact soon(红色)。
- 已用百分比,以及窗口里用掉了多少 token,比如「134.4k / 200k」。
- 最近 12 轮的小图表,用 ▁▂▃▄▅▆▇█ 画出来。
- 上一轮增加了多少,比如「▲ +98.3k last turn」。
每轮结束后都要更新。
Claude 会问一次,是否要为这次会话打开热重载。允许它,等 Claude 这一轮结束时,状态栏就会出现在提示框上方。之后每次改动都会原地重新加载,所以你可以不断提新要求(「让 Storm 从 70% 就开始显示」「在末尾加上美元花费」),同时看着状态栏变化。这个 mod 只在当前会话里加载,之后它的文件夹会被清理掉。想留住它,就把文件夹复制出来,当作普通插件安装(见第 6 步)。
注意,上面那段提示词只描述了你想看到什么。写 mod 不需要懂 API。Claude Code 内置了一份写 mod 的指南,讲的是怎么做:状态存在哪里才能在重载后保留、怎么用 claude plugin validate 检查插件、该挂哪些事件。把「它要显示这些内容」那几行改掉,它就是你的 mod,而不是我们的。
如果你更想先看看它是怎么搭起来的,或者想检查 Claude 写了什么,接着往下读。
第 1 步:创建文件夹
先确认你的 Claude Code 版本够新:
claude --version # 2.1.287 或更高
创建下面这个目录结构:
token-weather/
├── .claude-plugin/
│ ├── plugin.json
│ └── types/ (加载 mod 时由 Claude Code 生成)
├── hooks/
│ ├── hooks.json
│ └── token-weather.mjs
├── types/
│ └── index.d.ts (第 3 步加入)
└── tests/
└── token-weather.test.ts (第 5 步加入)
.claude-plugin/plugin.json 是标准的插件清单:
{
"name": "token-weather",
"version": "0.1.0",
"description": "A live forecast of the context window, drawn above the prompt.",
"author": { "name": "You" }
}
{
"modules": ["./token-weather.mjs"]
}
hooks/hooks.json 指向这个模块。一个 mod 只有一个这样的入口。
第二步:画点东西
提示框正上方那一条区域,对应一个叫 AbovePrompt 的组件。Claude Code 自己不会在那里画任何东西,所以拿它练手正合适。挂上它的 ui.render 事件,返回一棵元素树:
// hooks/token-weather.mjs
export function register(on) {
on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
const { Box, Text } = $.ui.resolve(e);
return Box({
paddingX: 1,
children: [Text({ color: "yellow", bold: true, children: "☀ Clear skies" })],
});
}
这些元素并不是全局的。$.ui.resolve(e) 返回的是当前正在绘制的那个界面对应的构造函数,因为 Claude Code 每个绘图界面支持的元素集合都略有不同。用 JSX 也行,工厂函数就在手边。
带上插件启动一个会话:
claude --plugin-dir ./token-weather
“☀ Clear skies”就会出现在提示框上方。保持会话开着。这个文件夹是被监听的,所以每次保存都会就地重新加载模块,无需重启。这种快速的反馈循环,正是写 mod 最大的乐趣所在。
小提示:一旦摸清了套路,就可以像那个快捷方式那样,把下一个 mod 直接讲给 Claude 听。它会把这个插件写到一个文件夹里,在同一会话里热重载。
第三步:读取真实数据,存进 $.state
$.session.usage() 返回的数据和状态栏上显示的完全一致。context.token 是上一次回答所基于的输入量,context.window 是模型的上下文窗口,context.percent 就是前者除以后者。这次调用不花钱:只有当你要求一份明细(breakdown)时,它才会发出一次 token 计数请求。
在会话开始时和每一轮结束后各读一次:
on("session.start", async ($, e, next) => {
const result = await next(e);
await takeReading($);
return result;
});
on("turn.complete", async ($, e, next) => {
const result = await next(e);
if (!e.agentId) {
await takeReading($); // 仅主循环的轮次,不含子代理
}
return result;
});
这两个钩子都先调用 next(e),然后再做观察。两者都不会改变实际发生的事情。
读数该存在哪里?模块级的 let readings = [] 看似理所当然,但热重载是一次全新的加载:register 会再跑一遍,session.start 会再次触发,模块变量也全部归零。把历史记录放进 $.state。它由宿主(host)在整个会话期间保存命名值,能挺过每次重载。
// Held by the host, so the history survives a hot reload of this file.
const readings = { plugin: "token-weather", key: "readings" };
async function takeReading($) {
const { context } = await $.session.usage();
if (!context?.window) return;
const tokens = context.tokens ?? 0;
const percent = context.percent ?? Math.round((tokens / context.window) * 100);
const { value: history = [] } = await $.state.get(readings);
await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));
}
状态值要在插件的类型契约(type contract)里声明,也就是一个小的 .d.ts 文件,由清单文件(manifest)指向它。新建 types/index.d.ts:
export type TokenWeatherReading = { tokens: number; window: number; percent: number };
declare module "claude-code" {
interface PluginState {
"token-weather": { readings: TokenWeatherReading[] };
}
然后在 plugin.json 里加上 "types": "./types/index.d.ts"。跳过这一步的话,claude plugin validate 会拦住你,并告诉你该怎么修:token-weather.readings is not declared: the manifest's types contract must name it in interface PluginState { … }。
这样做回报不小:重绘(redraw)可以白拿。在渲染钩子运行时调用 $.state.get,就等于让这块绘制订阅了对应状态,之后每次 $.state.set 都会触发重绘。完全不需要手动调用 $.ui.invalidate。
第 4 步:画出预报
下面是完整的模块代码:
// Token Weather: a live forecast of the context window, above the prompt.
const HISTORY = 12;
const BARS = "▁▂▃▄▅▆▇█";
const FORECAST = [
{ upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
{ upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
{ upTo: 75, icon: "☂", word: "Showers", color: "blue" },
{ upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
{ upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
];
// Held by the host, so the history survives a hot reload of this file.
const readings = { plugin: "token-weather", key: "readings" };
export function register(on) {
on("session.start", async ($, e, next) => {
const result = await next(e);
await takeReading($);
return result;
});
on("turn.complete", async ($, e, next) => {
const result = await next(e);
if (!e.agentId) {
await takeReading($); // main-loop turns only, not subagents
}
return result;
});
on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
const { value: history = [] } = await $.state.get(readings);
if (e.props.hasSurvey || history.length === 0) {
return next(e);
}
const { Box, Text } = $.ui.resolve(e);
return band(Box, Text, history, e.props.bodyColumns);
});
}
async function takeReading($) {
const { context } = await $.session.usage();
if (!context?.window) return;
const tokens = context.tokens ?? 0;
const percent = context.percent ?? Math.round((tokens / context.window) * 100);
const { value: history = [] } = await $.state.get(readings);
await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));
}
function band(Box, Text, history, columns) {
const now = history[history.length - 1];
const f = FORECAST.find((b) => now.percent < b.upTo);
const parts = [
Text({ color: f.color, bold: true, children: `${f.icon} ${f.word}` }),
Text({ children: ` ${now.percent}% of context` }),
Text({ dimColor: true, children: ` ${short(now.tokens)} / ${short(now.window)}` }),
];
if (columns >= 60) {
parts.push(Text({ dimColor: true, children: " last turns " }));
parts.push(Text({ color: f.color, children: sparkline(history) }));
if (history.length > 1) {
parts.push(Text({ dimColor: true, children: trend(history) }));
}
return Box({ flexDirection: "row", paddingX: 1, children: parts });
}
function sparkline(history) {
const top = Math.max(...history.map((r) => r.tokens), 1);
return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join("");
}
function trend(history) {
const delta = history[history.length - 1].tokens - history[history.length - 2].tokens;
if (delta === 0) return " steady";
return delta > 0 ? ` ▲ +${short(delta)} last turn` : ` ▼ ${short(-delta)} last turn`;
}
function short(n) {
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
return String(n);
}
有三处细节,建议你自己的 mod 直接照抄:
-
组件的
props里有一个props.hasSurvey,告诉你有调查正在占用输入区,此时 hook 要用next(e)让位。bodyColumns是可用区的真实宽度,当有面板停靠在对话记录旁边时,它会比终端窄。渲染树的宽度要按它来算。顶层只放e.component、e.surface、e.requestId和e.viewport。 -
没东西可画时就直接放行。返回
next(e),把这块区域交还给 Claude Code 和其他 mod。 -
用等宽符号,别用 emoji。☀ ☁ ☂ ☇ ↯ 在任何终端字体里都能对齐。
保存文件,正在运行的会话会自动加载。几轮读取大文件之后,输入区上方那条状态带就会从 Clear 变成 Showers 再到 Storm,跟本节开头的录屏一样。
第 5 步:验证与测试
claude plugin validate 会按 Claude Code 实际的加载方式读取 manifest 和模块源码,并报告模块挂载了哪些 hook、调用了哪些接口:
$ claude plugin validate ./token-weather
> types ./types/index.d.ts declares state: token-weather.readings
> ./token-weather.mjs hooks: session.start, turn.complete, ui.render{component=AbovePrompt}
> ./token-weather.mjs calls: $.session.usage (via takeReading), $.state.get, $.state.set (via takeReading), $.ui.resolve
> ./token-weather.mjs state writes: token-weather.readings
> ./token-weather.mjs state reads: token-weather.readings
√ Validation passed
claude plugin test 会把插件的 *.test.ts 文件跑在真实的 Claude Code 运行时上。测试里用 on 注册的 hook,会排在链中 mod 之后执行,并覆盖 Claude Code 原本会给出的返回值,所以 $.session.usage() 返回什么,完全由你说了算:
// tests/token-weather.test.ts
import { describe, expect, test } from "claude-code/testing";
describe("token-weather", () => {
test("the band follows the context window", async ($, on) => {
// Hooks registered here run after the mod and stub what Claude Code would answer.
let tokens = 36_100;
on("session.start", ($, e) => ({ cwd: e.cwd }));
on("session.usage", () => ({
value: { startedAt: 0, rateLimits: [], context: { tokens, window: 200_000, percent: Math.round(tokens / 2_000) } },
}));
on("turn.complete", () => ({ text: "" }));
await $.session.start({ surface: "terminal", isInteractive: true, cwd: "/work" } as any);
const ui = await $.ui.mount({
plugin: "token-weather",
surface: "terminal",
component: "AbovePrompt",
props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },
} as any);
expect(await ui.find({ type: "Text", text: /Clear/ })).toBeDefined();
tokens = 134_400;
await $.turn.complete({ reason: "answer", answer: "ok", durationMs: 1 } as any);
expect(await ui.find({ type: "Text", text: /Showers/ })).toBeDefined();
expect(await ui.find({ type: "Text", text: /67% of context/ })).toBeDefined();
expect(await ui.find({ type: "Text", text: /▲ \+98\.3k last turn/ })).toBeDefined();
await ui.unmount();
});
$ claude plugin test ./token-weather
(pass) token-weather > the band follows the context window
1 pass
0 fail
测试还会检查第 3 步里的重绘行为:band 在 turn.complete 之后会自动更新,mod 全程没有主动请求过重绘。
mod 本质上是插件,所以发布方式完全一样。把它放进 marketplace 即可,marketplace 可以简单到只是一个带 .claude-plugin/marketplace.json 的文件夹:
{
"name": "my-mods",
"owner": { "name": "You" },
"plugins": [{ "name": "token-weather", "source": "./token-weather" }]
}
claude plugin marketplace add ./my-mods
claude plugin install token-weather@my-mods --scope user
分享你的 mod
mod 就是 Claude Code 插件,分享方式和普通插件一模一样,没有任何新东西要学。把 mod 放进一个 GitHub 仓库,加一个 marketplace 文件,这个仓库就成了你的 marketplace。任何人都能从里面安装,更新也只需普通 push 一次。
在 Claude Code 里安装只需三条命令:
/plugin marketplace add your-org/my-mods
/plugin install token-weather@my-mods
/reload-plugins
重载后 mod 就会启动。如果没有出现,重启一下 Claude Code。
mod 是在你本机的 Claude Code 里运行的代码,拥有和 Claude Code 同等的权限,而且由发布者自己编写,不是 Anthropic 出的。所以安装 mod 就跟安装普通软件包一样:先读仓库,只从你信任的人那里装。在你敲下命令之前,什么都不会被安装。
Claude 目录接受包含 mod 的插件,你可以在 claude.ai/directory/manage 提交自己的插件,这样别人不用找你拿链接也能找到。
另外两个 mod
Token Weather 只做观察和绘制。下面两个 mod 会介入事件、打开面板,还会接收输入。
Blast Radius:在危险命令执行前,先看看它会改动什么
当 Claude 调用 Bash 执行 rm -rf、git reset --hard、git clean、强推(force push)或数据库迁移时,Blast Radius 会拦下这次调用。它先算出这条命令会影响到哪些东西,然后弹出一个面板,给出「继续(Proceed)」和「取消(Cancel)」两个选项。按 2,Claude 会收到一条带原因的拒绝。按 1,命令按原样执行。
FIG DBlast Radius 会拦住 rm -rf build,并列出它即将删掉的 9 个文件(1.1 MB)。点 Cancel 就拒绝执行;再试一次点 Proceed,命令才会跑起来。
它用了三个钩子:Bash 上的 tool.call,以及 Pane 和 AbovePrompt 上的 ui.render。核心就是上面那张表里的「answer」招式:
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
const risk = classify(String(e.command ?? ""));
if (risk === null) return next(e); // 其他命令照常执行
const report = await measure($, risk, await $.session.cwd()); // git status、git clean -n、du……
held = { command: e.command, risk, report, decision: null };
const opened = await $.ui.open({ id: "blast-radius", title: "Blast Radius", focus: true });
if (!opened.isPlaced) held.where = "band"; // 空间太窄放不下面板,就画在输入框上方
while (held.decision === null && !next.signal.aborted) {
await $.process.run(["sleep", "0.25"]); // 在 $ 调用里耗的时间不计入钩子时限
}
if (held.decision === "proceed") return next(e); // 放行
return { deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}.` };
});
从中可以学到几点:
-
用
$.process.run做干跑(dry run)。报告来自工具自身的命令:git status --porcelain、git clean -n、git log HEAD..origin/main、showmigrations。参数以 argv 数组传入,所以路径里不会有什么东西被当成 shell 代码执行。 -
把一次调用挂起。钩子每次派发有自己的 10 秒时限,但在
$调用里等待的时间不算。这个循环靠反复跑短sleep进程来等待,直到某个按钮的onPress设定了 decision;一旦next.signal被中止(你按了 Esc),它就放弃。 -
带快捷键的按钮。
Button({ label: "Proceed", hotkey: "1", onPress })用鼠标点、用 Tab 加回车、或直接按数字键都能触发。 -
降级到 band。终端够宽时,会把面板停靠在会话记录旁边。当
$.ui.open返回isPlaced: false时,同一份报告就画在输入框上方:

图 E 终端宽度为 120 列时,Blast Radius 会在提示符上方区域为 git reset --hard 画出报告
它是一张安全网,不是权限系统。它只读命令文本,所以 $(…)、别名、以及内部调用 rm 的脚本都能绕过去。要硬性拦截,还得靠权限规则。
Replay Theater:逐步回放上一轮的编辑
一轮对话执行时,Replay Theater 会记录每一次 Edit 和 Write 调用:涉及哪个文件,以及修改前后的文本。轮次结束后,提示符上方会出现一条提示。按 r(或输入 /replay),就会弹出一个面板,一次一个 diff 地走完所有编辑,面板上有一排带编号的步骤,还有 Prev、Next、Close 按钮。
图 F Replay Theater:3 个文件、5 次重命名后的提示,然后是面板中的第 1 到第 5 步
它从不会阻拦或改动编辑,只是旁观:
on("tool.call", async ($, e, next) => {
if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e))); // old/new text → diff
return next(e); // the edit runs untouched
});
on("turn.start", ($, e, next) => { if (!e.agentId) state.pending = []; return next(e); });
on("turn.complete", async ($, e, next) => {
const r = await next(e);
if (!e.agentId && state.pending.length) state.replay = state.pending; // one replay per turn
return r;
});
on("session.start", async ($, e, next) => {
const r = await next(e);
await $.command.register({ name: "replay", description: "Step through the last turn's file edits" });
return r;
});
on("command.run", { command: "replay" }, async ($, e) => ({ text: (await openReplay($)) ? "Replaying" : "No edits" }));
这段代码说明了什么:
- 把
turn.start和turn.complete两个事件配成一对,就能按轮次把编辑归为一组回放;用e.agentId可以排除子代理的轮次,不让它们混进分组。 -
注册斜杠命令。在
session.start里调用$.command.register,然后在command.run上响应它。 -
读取文件。执行 Write 时,
$.fs.read拿到的是写入落地前的旧内容,所以 diff 是真实的。 -
摆在哪里由 surface 决定。全屏模式下,面板停靠在右侧;只有 80 列宽时,它内联显示在输入提示上方。不管哪种方式,mod 画出的都是同一棵树。

图:Replay Theater 在 80 列宽下内联显示于输入提示上方
四个值得保留的习惯
-
善用 Claude Code 为你生成的类型。每次加载你的 mod,Claude Code 都会把构建所需的声明文件写进该 mod 的
.claude-plugin/types/目录,编辑器与tsc -p无需额外步骤即可工作。这些声明是每个事件、$上每个方法、每个元素 props 的权威参考。 -
props 要从
e.props里读。hasSurvey、bodyColumns等等都实打实地放在那里,而不是在one自身上。 -
为热重载做好打算。每次保存都会重新执行
register和session.start,所以数据要存在$.state里,别放在模块变量中。 -
画面不出来时,去看日志。运行
claude --debug,找一行提示某个 hook 返回的树未通过校验的记录。
你想 mod 点什么?
本文这三个 mod 各自源自一个问题:我的上下文有多满?这条命令要删掉什么?Claude 刚改了什么?你的问题会不一样,这正是重点。这里有些思路可以参考:
-
用
$.session.usage()做一个成本或速率限制仪表,配合$.ui.status显示在状态栏上 -
写一个
prompt.submithook,把团队的约定追加到每个提示词里 -
做一个面板,列出本次会话中 Claude 读过的文件,相当于它「看过什么」的实时地图
-
做一个专注计时器,长回合结束时通过
$.ui.toast弹出提示