教程 · CLAUDE

CLAUDE发布日期 2026-10-01 · 阅读约 10 分钟

开始使用 Claude Code mods

> 从空文件夹开始构建你的第一个 Claude Code mod,再看看这个 API 还能做什么。

原作者/来源:claude.dev

原文链接:https://claude.dev/blog/getting-started-with-claude-code-mods/

从空文件夹开始构建你的第一个 Claude Code mod,再看看这个 API 还能做什么。

mod 是一个在 Claude Code 会话中运行的小型 JavaScript 或 TypeScript 文件。它可以观察正在发生的事情、改变 Claude Code 的行为,或者在终端或桌面应用中绘制自己的 UI。你不需要先学习 API 就能尝试。运行 claude,然后描述你想要的 mod。按提示允许热重载,当前回合结束时 mod 就会显示出来。

Claude Code 已经允许你改变它的许多行为:设置、权限规则、斜杠命令、技能和状态行。mod 更进一步:它们可以改写或替换 Claude Code 的行为,还能绘制自定义 UI。在底层,mod 是随插件一起分发的 hook,每个 hook 都会在会话中实时看到发生的每一个事件。

因此,mod 可以让 Claude Code 适应你的工作方式。你可以添加一个自己经常查看的信息读数,在让你不放心的命令前加一道防护,或者按照自己阅读变更的习惯构建一个审查视图。

本指南从一个空文件夹开始构建一个 mod:Token Weather。它会在提示词上方绘制一份上下文窗口的实时天气预报,代码大约 80 行。接着,文章会介绍另外两个更大的 mod——Blast Radius 和 Replay Theater,展示这个 API 还能做什么。这三个 mod 的完整代码都在 anthropics/claude-code-playground 中。

终端 桌面 0:00 / 1:19

原文图 A

图 A:Token Weather、Blast Radius 和 Replay Theater 在终端会话中依次运行

图 B:上下文窗口逐渐填满时 Token Weather 的状态条:18% 时为 Clear,67% 时为 Showers,81% 时为 Storm

需要 Claude Code 2.1.287 或更高版本。mods 默认已启用,无需另行开启。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 的形式都相同:

代码:JavaScript

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  // $ 是 mod API:ui、session、state、store、fs、process、clock、http、tool、command、model,等等
  // e 是此事件的输入,采用普通数据格式
  // next 会把 e 传给其他插件,然后传给 Claude Code 自身的行为
  return next(e);
});

hook 会像中间件一样组成一条链。先运行你的 hook,next(e) 会把事件交给下一个插件;到链的末端时,Claude Code 会照常执行原本要做的事情。一个 hook 可以做以下三件事之一:

hook 会组成一条链

原文图 A

事件先经过你的 hook,再经过其他插件,最后到达 Claude Code

事件

next(e) next(e)

tool.call → 你的 hook → 其他插件 → Claude Code

e = { command } → ($, e, next) → 相同形状 → 执行工具

结果 结果

  • 观察:await next(e),然后查看结果
  • 改写:next({ ...e, command })
  • 作答:不调用 next,直接返回 { deny }

操作方式示例

操作 做法 示例
观察 const r = await next(e); /* look */ return r 记录每次文件编辑;每轮之后读取一次。
改写 return next({ ...e, command: safer }) 改变这条链后续部分看到的内容。
作答 不调用 next,返回 { deny: "…" } 拒绝一次工具调用;自行提供命令或工具。

这些事件涵盖工具调用、提交的提示词、回合的开始和结束、会话的开始和结束、斜杠命令,以及 ui.render——界面绘制时的每个部分。模块运行在自己的沙箱中,没有 DOM,也没有 Node,因此所有外部操作都要通过 $ 完成。

与设置中的 hook 有何不同

设置中的 hook 会针对每个事件运行一条 shell 命令,并通过 stdin 和 stdout 传递 JSON。mod 只加载一次,并在整个会话中保持运行。它可以保留状态、绘制会随事件更新的 UI,还可以调用 Claude Code 的功能:打开窗格、运行进程、注册斜杠命令,或注册一个模型可以调用的工具。

Claude Code 自己也会使用 mods。Claude Code 的部分自有功能就是用 mods 构建的,包括对 AGENTS.md 的支持,以及对话旁边的 /diff 窗格。它们的源代码和测试位于公开仓库 anthropics/claude-code 的 mods/ 目录下,你可以在那里了解团队如何构建这些功能。

构建你的第一个 mod:Token Weather

Token Weather 会在每轮之后读取上下文窗口的占用情况,并在提示词上方绘制一行内容:天气图标、占用百分比、已用 token 数与窗口容量、近期回合的小图表,以及上一轮增加了多少内容。

已使用 预报
低于 25% ☀ Clear(晴朗)
25–49% ☁ Cloudy(多云)
50–74% ☂ Showers(阵雨)
75–89% ☇ Storm(风暴)
90% 及以上 ↯ 即将压缩

下面是在真实会话中的效果。每一轮都会读取更多文件,状态条会从 ☀ Clear 逐步填充到 ☂ Showers,再到 Storm:

☇

终端 桌面 0:00 / 0:26

原文图 C

图 C:Token Weather 在完整终端会话中的效果,跨越三轮:在 200k 窗口中分别占 18%、67% 和 81%

快捷方式:让 Claude 来构建

你可以跳过这六个步骤。Claude Code 知道如何编写 mods,所以你只需描述想要的 mod,让它来完成工作。用 claude 开始一个会话,然后粘贴下面的提示词:

代码:文本

给我制作一个名为 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 步)。

注意,这段提示词只描述了你想看到什么。你不需要了解 API 就能写出 mod。Claude Code 内置的 mod 编写指南会介绍具体做法:如何保存状态使其在重新加载后仍然存在,如何用 claude plugin validate 检查插件,以及应该 hook 哪些事件。只要改写“它应该显示什么”那几行,做出来的就是你的 mod,而不是我们的。

如果你想先看看它是如何组装的,或者想检查 Claude 写出的内容,请继续阅读。

步骤 1:创建文件夹

检查你的 Claude Code 版本是否足够新:

代码:Shell

claude --version # 2.1.287 或更高版本

创建以下目录结构:

代码:文本

token-weather/
├── .claude-plugin/
│   ├── plugin.json
│   └── types/            (Claude Code 加载 mod 时写入)
├── hooks/
│   ├── hooks.json
│   └── token-weather.mjs
├── types/
│   └── index.d.ts        (在步骤 3 中添加)
└── tests/
    └── token-weather.test.ts   (在步骤 5 中添加)

.claude-plugin/plugin.json 是标准插件清单:

代码:JSON

{
  "name": "token-weather",
  "version": "0.1.0",
  "description": "A live forecast of the context window, drawn above the prompt.",
  "author": { "name": "You" }
}

hooks/hooks.json 指向该模块。一个 mod 恰好只有一个模块:

代码:JSON

{
  "modules": ["./token-weather.mjs"]
}

步骤 2:绘制一些内容

提示词正上方的状态条是一个名为 AbovePrompt 的组件。Claude Code 自己不会在这里绘制任何内容,所以这是一个很好的起点。hook 它的 ui.render 事件,并返回一个元素树:

代码:JavaScript

// 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,并以 h 作为工厂函数。

启动一个加载了该插件的会话:

代码:Shell

claude --plugin-dir ./token-weather

提示词上方会出现“☀ Clear skies”。保持会话打开。系统会监视这个文件夹,因此每次保存都会原地重新加载模块,无需重启。这种快速反馈循环正是编写 mods 的乐趣所在。

提示:熟悉这种结构后,你可以像使用快捷方式那样描述下一个 mod。Claude 会把插件写到一个文件夹里,并在同一会话中热重载。

步骤 3:读取真实数值,并将其保存在 $.state 中

$.session.usage() 会返回与状态行相同的数据。context.tokens 是上一条回复所依据的输入 token 数,context.window 是模型的窗口大小,而 context.percent 是前者与后者的比值。这个调用是免费的:只有在你要求细分数据时,它才会发送 token 计数请求。

在会话开始时和每轮结束后读取一次:

代码:JavaScript

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

这两个 hook 都会先调用 next(e),再进行观察。它们都不会改变后续行为。

应该把读数放在哪里

在模块顶层使用 let readings = [] 看起来很自然,但热重载会重新加载模块:register 会再次运行,session.start 会再次触发,模块变量也会重置。因此,应把历史数据放在 $.state 中。它会在宿主中为整个会话保存带名称的值,而且这些值在重新加载后仍然存在。

代码:JavaScript

// 由宿主保存,因此此文件热重载后历史记录仍会保留。
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));
}

状态值要在插件的类型契约中声明。这个契约是一个由清单指向的小型 .d.ts 文件。添加 types/index.d.ts:

代码:TypeScript

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;清单的类型契约必须在 interface PluginState { … } 中列出它。

这样做的回报是,界面会自动重绘。在 render hook 运行时调用 $.state.get,会让这次绘制订阅该状态;之后每次 $.state.set 都会重绘状态条。你不需要调用 $.ui.invalidate。

步骤 4:绘制天气预报

下面是完整模块:

代码:JavaScript

// Token Weather:在提示词上方显示上下文窗口的实时天气预报。

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" },
];

// 由宿主保存,因此此文件热重载后历史记录仍会保留。
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($); // 仅主循环回合,不包括子智能体
    }
    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);
}

有三点值得借鉴到你自己的 mods 中:

  • 组件的属性位于 e.props。hasSurvey 表示有调查问卷需要显示,因此 hook 应调用 next(e),让调查问卷优先显示。bodyColumns 是状态条实际可用的宽度;当窗格停靠在文字记录旁边时,这个宽度会比终端窄。应据此调整元素树的大小。e 的顶层只有 e.component、e.surface、e.requestId 和 e.viewport。
  • 没有内容要绘制时就传递下去。返回 next(e) 会把状态条交还给 Claude Code 和其他 mods。
  • 使用单宽字符,不要使用 emoji。☀ ☁ ☂ ☇ ↯ 在每种终端字体中都能对齐。

保存文件后,正在运行的会话就会加载它。读取几个大型文件、经过几轮之后,状态条会从 Clear 变为 Showers,再变为 Storm,就像本节开头录屏所示。

步骤 5:验证和测试

claude plugin validate 会像 Claude Code 一样读取清单和模块源代码,并报告模块 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 会在真正的 Claude Code 运行时中运行插件的 *.test.ts 文件。测试通过 on 注册的 hook 会在链中该 mod 之后运行,并为 Claude Code 原本会返回的内容提供桩,因此你可以精确控制 $.session.usage() 返回什么:

代码:TypeScript

// 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) => {
    // 这里注册的 hook 会在 mod 之后运行,并为 Claude Code 原本会返回的内容提供桩。
    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();
  });
});

代码:Shell

$ claude plugin test ./token-weather
(pass) token-weather > the band follows the context window
1 pass
0 fail

这个测试也检查了步骤 3 中的重绘行为。turn.complete 之后,状态条会自动更新,mod 本身不需要请求重绘。

步骤 6:分享

mod 就是插件,因此分发方式也相同。把它放到一个 marketplace 中;marketplace 可以只是一个包含 .claude-plugin/marketplace.json 的文件夹:

代码:JSON

{
  "name": "my-mods",
  "owner": { "name": "You" },
  "plugins": [{ "name": "token-weather", "source": "./token-weather" }]
}

代码:Shell

claude plugin marketplace add ./my-mods
claude plugin install token-weather@my-mods --scope user

要把你的版本与完成版进行比较,请查看 anthropics/claude-code-playground 中完整的 Token Weather mod。

分享你的 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 应像安装软件包一样谨慎:先阅读仓库,只安装你信任的人的 mod。只有在你运行命令后,才会进行安装。

Claude Directory 接受包含 mods 的插件。你可以在 claude.ai/directory/manage 提交自己的插件,这样人们无需你提供链接也能找到它。

另外两个 mod

Token Weather 只负责观察和绘制。下面两个 mod 会参与事件、打开窗格并接收输入。

这两个 mod 的完整代码都在 anthropics/claude-code-playground 中:Blast Radius 和 Replay Theater。每个都附有 README,说明它的构建方式。

Blast Radius:在高风险命令运行前,查看它会改动什么

当 Claude 通过 Bash 调用 rm -rf、git reset --hard、git clean、强制推送或数据库迁移时,Blast Radius 会暂停这次调用。它会计算该命令可能影响的内容,并打开一个带有 Proceed 和 Cancel 按钮的窗格。按 2,Claude 会收到一条附有原因的拒绝信息。按 1,命令就会按原样运行。

终端 桌面 0:00 / 0:24

原文图 D

图 D:Blast Radius 暂停了 rm -rf build,并列出它将删除的 9 个文件(1.1 MB)。第一次按 Cancel 会拒绝该操作;第二次尝试时按 Proceed,则会运行该命令。

它使用三个 hook:在 Bash 上 hook tool.call,并分别在 Pane 和 AbovePrompt 上 hook ui.render。它的核心是上表中的“作答”操作:

代码:JavaScript

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"]);          // 在 $ 调用中花费的时间不计入 hook 的时限
  }
  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 进行试运行。报告来自这些工具自身的命令:git status --porcelain、git clean -n、git log HEAD..origin/main、showmigrations。参数以 argv 数组形式传入,因此路径中的内容不会作为 shell 代码执行。
  • 暂停一次调用。每次分派时,hook 自身有 10 秒执行时间;但在 $ 调用内部等待所花的时间不计入其中。这个循环会等待短暂的 sleep 进程,直到按钮的 onPress 设置决策;如果 next.signal 被中止(你按下了 Esc),循环就会退出。
  • 使用快捷键的按钮。Button({ label: "Proceed", hotkey: "1", onPress }) 可以通过鼠标点击、按 Tab 再按 Enter,或直接按对应数字来操作。
  • 窗格放不下时退回状态条。终端足够宽时会在文字记录旁边停靠一个窗格。如果 $.ui.open 返回 isPlaced: false,同一份报告就会绘制在提示词上方:

原文图 E

图 E:在 120 列宽度下,Blast Radius 会在提示词上方的状态条中绘制 git reset --hard 的报告

它是一个安全网,不是权限系统。它读取的是命令文本,因此 $(…)、别名以及会调用 rm 的脚本都可能绕过它。要彻底阻止命令,请使用权限规则。

Replay Theater:逐步查看上一轮的编辑

一轮运行期间,Replay Theater 会记录每次 Edit 和 Write 调用:文件,以及前后文本。回合结束后,提示词上方会出现提示。按 r(或输入 /replay),窗格就会逐个 diff 展示这些编辑,并提供一列带编号的步骤以及 Prev、Next 和 Close 按钮。

终端 桌面

0:00 / 0:27

原文图 F

图 F:Replay Theater:一次涉及 3 个文件的 5 处重命名编辑后显示提示,然后在窗格中依次展示步骤 1 至 5

它绝不会阻止或修改编辑,只会进行观察:

代码:JavaScript

on("tool.call", async ($, e, next) => {
  if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e)));  // 前后文本 → diff
  return next(e);                                                              // 不干预编辑,照常运行
});

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;       // 每轮生成一次回放
  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 反映的是真实差异。
  • 由界面决定放置位置。在全屏模式下,窗格会停靠在右侧;在 80 列宽度下,则会内嵌显示在提示词上方。无论哪种情况,mod 绘制的都是同一棵元素树。

原文图 G

图 G:在 80 列宽度下,Replay Theater 内嵌绘制在提示词上方

值得保持的四个习惯

  • 充分利用 Claude Code 为你生成的类型。每次加载 mod 时,Claude Code 都会把当前构建版本的声明写入 mod 的 .claude-plugin/types/ 文件夹,因此编辑器和 tsc -p 无需额外设置就能正常工作。这些声明是所有事件、$ 上的所有方法,以及每个元素属性的参考依据。
  • 从 e.props 读取属性。hasSurvey、bodyColumns 等属性都在这里,而不在 e 本身上。
  • 为热重载做好准备。每次保存都会再次运行 register 和 session.start,因此数据要保存在 $.state 中,而不是模块变量中。
  • 绘制内容没有显示时,查看日志。运行 claude --debug,查找提示某个 hook 返回了无效元素树的日志行。

你会做什么 mod?

这里的三个 mods 分别源自一个问题:我的上下文用了多少?这个命令马上要删除什么?Claude 刚刚改了什么?你的问题会有所不同,这正是关键。下面是一些可以着手尝试的想法:

  • 用 $.session.usage() 创建成本或速率限制计量器,并通过 $.ui.status 将它显示为状态行。
  • 添加一个 prompt.submit hook,把团队规范加入每一条提示词。
  • 创建一个窗格,列出 Claude 在本次会话中读取过的文件,作为它所查看内容的实时地图。
  • 创建一个专注计时器,在长时间回合结束时用 $.ui.toast 发送 toast 提示。
  • 根据你的技术栈定制 tool.call 防护,例如检查生产环境的 kubectl 上下文或 terraform apply。

分享你的作品

你做了一个每天都在用的 mod 吗?在 X 或 LinkedIn 上发布它,配上一段 GIF 或运行截图,让其他开发者也看看它能做到什么。把插件放进一个 marketplace(参见“分享你的 mod”)并附上链接,这样感兴趣的人只需三条命令就能安装。