Claude Code mod 入门

HN Claude Code Adoption 2026-10-02T05:53:47.932344

从空文件夹开始做出你的第一个 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 在终端会话里依次出现

一条单行终端横条在三种预报间轮换:Clear 配黄色太阳,Showers 配蓝色雨伞,Storm 配粉色闪电,每个都显示 20 万 token 中的已用数量和最近几轮的迷你柱状图。

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 模块里:

每个 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 直接照抄:

保存文件,正在运行的会话会自动加载。几轮读取大文件之后,输入区上方那条状态带就会从 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}.` };
});

从中可以学到几点:

提示符上方有一个黄边方框,显示命令、将被丢弃的两个未提交变更文件,以及带编号的「继续」和「取消」选项;终端没有侧边面板。

图 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" }));

这段代码说明了什么:

一个纵向拉长的终端窗口,输入提示上方有一个洋红色边框的方框:一条带编号的步骤条、文件 greet.js、一行 diff,以及 Prev、Next、Close 按钮。

图:Replay Theater 在 80 列宽下内联显示于输入提示上方

四个值得保留的习惯

你想 mod 点什么?

本文这三个 mod 各自源自一个问题:我的上下文有多满?这条命令要删掉什么?Claude 刚改了什么?你的问题会不一样,这正是重点。这里有些思路可以参考:

查看原文