黄奕彬

外部精选

Getting started with Claude Code mods

本文来自外部来源,不是黄奕彬的原创文章。本站提供原文入口,并在采集端生成 AI 结构化摘要、解读与中文翻译(如有),一切以原文为准。

阅读原文(claude.dev)
来源
claude.dev Blog ↗
作者 / 署名
Addy Osmani、Addy Osmani
发布日期
本站收录时间
原文域名
claude.dev

以上时间均为北京时间;来源更新时间由来源提供,不代表首次发布时间。

结构化摘要 AI 生成

背景与问题

面向已在使用 Claude Code(终端或桌面 app)的开发者。此前 Claude Code 已支持通过 settings、权限规则、slash commands、skills 和 status line 定制行为,但无法重写或替换其行为、也无法绘制自定义 UI。

本文要解决的问题:介绍新的 mods 机制并给出从零编写第一个 mod 的入门路径。

方法或内容

文章先解释 mods 架构:mod 是随插件(plugin)发布的 hooks,能看到会话中的每个事件,钩子形成中间件链,可 Observe(观察)、Rewrite(改写)或 Answer(拦截应答),模块运行在无 DOM、无 Node 的沙箱中,一切经 $ API 访问。

随后从空文件夹逐步构建约 80 行的 Token Weather mod(在提示词上方绘制上下文窗口占用率的实时预报条),并给出让 Claude 代写的快捷方式。再巡览两个更大示例:Blast Radius(危险命令执行前预览影响)与 Replay Theater(逐条回放上一 turn 的文件编辑)。

完整代码在 anthropics/claude-code-playground。

关键发现 / 结果

关键技术点:插件目录含 .claude-plugin/plugin. 与 hooks/hooks.;模块导出 register(on, options),用 on(event, matcher?, hook) 注册钩子,签名 ($, e, next);事件覆盖 tool calls、prompt 提交、turn 起止、session 起止、slash commands 与 ui.render。

热重载下模块变量会重置,状态须存 $.state。claude plugin validate 校验 manifest 与模块(含 state 声明契约),claude plugin test 用 *.test.ts 在真实运行时测试。

分发走 marketplace,三条命令安装,可提交至 claude.ai/directory/manage。Claude Code 自身的 AGENTS.md 支持与 /diff 面板也以 mods 构建,源码与测试在公开仓库 anthropics/claude-code 的 mods/ 目录。

hook 每次派发有 10 秒自身时间,但在 $ 调用内等待的时间不计入。

结论与意义

mods 是把 Claude Code 适配到个人工作流的方式:常驻会话、保持状态、绘制随事件更新的 UI、注册 slash command 或模型可调用的 tool。

适用边界:Blast Radius 明确是安全网而非权限系统,$(…)、别名和调用 rm 的脚本可绕过,硬拦截应用权限规则;mod 在本机以与 Claude Code 相同的访问权限运行且由发布者而非 Anthropic 编写,只应安装可信来源;API 可能在版本间变化,以加载时写入 .claude-plugin/types/ 的类型声明为权威。

AI 解读 AI 生成

方法:按「时空变量」分析(时间 / 空间 / 变量,严格区分包含、相关与因果),对象包括原文作者、署名人员与关键技术及其实际原理;结论用「元反空」(元思考、反思考、空思考、综合)整理。

时间:何时发生,现在是否仍成立

来源日期为 2026-10-01,内容基于 Claude Code 2.1.287 或更新版本,mods 在该版本起默认开启。

原文明确说明 API 可在版本间变化、每次加载时由 Claude Code 重写类型声明作为该版本的权威,因此具体事件名、$ API 形态、组件名(如 AbovePrompt)、10 秒时限等细节在现在是否仍成立不确定;机制层面的描述(插件内 hooks 链)相对更可能持续,但原文未说明其长期稳定性。

空间:谁的环境 / 场景

适用于 Claude Code 用户,要求版本 2.1.287 或更新,终端与桌面 app 均可;mods 默认开启、无需配置。编写者需要或借助 Claude 代写 JavaScript/TypeScript。运行环境是独立沙箱,无 DOM、无 Node,外部能力须经 $。

分发形态为标准插件加 marketplace(可为 GitHub 仓库),也可经 Claude directory 分发;测试与验证依赖 claude plugin validate / claude plugin test 命令。

变量:实际改变了什么

相对既有定制手段(settings、权限规则、slash commands、skills、status line),mods 新增的能力是:重写或替换 Claude Code 的行为、绘制自定义 UI、随事件实时更新、驻留会话并保持状态、调用回 Claude Code(打开 pane、运行进程、注册 slash command、注册模型可调用的 tool)。

与 settings hooks 的差异:settings hooks 每次事件起 shell 命令并经 stdin/stdout 传 JSON,mod 加载一次常驻会话。价格与收费变化原文未说明。

区分:包含 ≠ 相关 ≠ 因果

包含关系:mod 是 Claude Code 插件,hooks 随插件发布;AGENTS.md 支持与 /diff 面板是 Claude Code 自身功能中以 mods 实现的部分。相关而非因果:Token Weather 中天气图标与上下文占用同现(图标只是展示层);三个示例 mod 与提问一一对应仅是设计动机。

有明确因果证据的:在渲染钩子中 $.state.get 会订阅该绘制,此后每次 $.state.set 自动重绘;未在 PluginState 声明 state 时 claude plugin validate 报错并给出修复提示;Write 前 $.fs.read 读到旧内容使 diff 真实;在 $ 调用内等待不计入 hook 时间限制;按 Cancel 返回 deny 导致工具调用被拒绝。使用 mods 与效率提升之间原文未提供数据,无因果证据。

对象:作者/署名与关键技术原理

署名作者仅来自结构化字段:Addy Osmani(字段中重复出现两次,实为一人)。关键技术按原文:mod 是运行在 Claude Code 会话内的小型 JavaScript/TypeScript 文件,本质是插件内的 hooks,能观察会话每个事件。

结构上 .claude-plugin/plugin. 为标准 manifest,hooks/hooks. 指定唯一模块;模块导出 register(on, options),on(event, matcher?, hook) 注册钩子,钩子签名为 ($, e, next),其中 $ 是 mods API(ui、session、state、store、fs、process、clock、http、tool、command、model 等),e 是事件输入,next(e) 将事件传给其余插件最终传给 Claude Code 原有行为。

三种动作:观察(先 next 后看结果)、改写(next 传入修改后的事件)、应答(不调 next 直接返回如 { deny })。模块在无 DOM、无 Node 的沙箱运行。$.state 由宿主持有、跨热重载存活。UI 经 $.ui.resolve(e) 取当前 surface 支持的元素构造器,JSX 亦可。

元反空综合结论

元:本文本质在解决『把 Claude Code 从可配置工具改造成可编程工具』的问题——将扩展从一次性 shell 脚本(settings hooks)升级为常驻、有状态、可绘制 UI、可拦截事件的模块,并以『让 Claude 代写』降低门槛。

反:最可能失效或有边界的部分——原文自认 API 会在版本间变化,所有事件名、$ 方法、组件名、验证器输出、10 秒时限等细节均绑定特定构建;Blast Radius 基于命令文本分类,作者自己声明可被 $()、别名绕过,不能当权限边界;『不需要学 API』的叙事依赖 Claude 代写质量,原文未给出可靠性数据;mods 以与 Claude Code 相同权限在本机运行,安全模型完全依赖安装者的来源审查。

空:去掉宣传叙事后剩下的必要事实——mod 是含 manifest 的插件内模块,注册 hooks 形成中间件链,可观察/改写/拦截事件并绘制 UI;状态须放 $.state 以存活热重载;类型声明由 Claude Code 生成并作为权威;分发走 marketplace、三条命令安装;Claude Code 自身部分功能即以此构建且源码公开。

综合:这是一份与版本 2.1.287+ 强绑定的可信实操教程,机制描述具体、可验证(含命令输出与测试代码),但其 API 细节应视为易变接口,参照官方生成的类型声明使用;作为扩展生态的入口文档有效,作为安全或稳定性承诺无效。

中文译文 AI 生成

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

Claude Code 已经允许你对它的行为做出很多改变:settings、permission rules、slash commands、skills 和 status line。Mods 更进一步:它们可以重写或替换 Claude Code 的所作所为,并绘制自定义 UI。

在底层,mods 是内置于插件中发布的 hooks,每一个都会在事件发生时实时看到你会话中的每一个事件。

这使 mods 成为一种让 Claude Code 适应你工作方式的方法。你可以添加一个自己时刻查看的读数显示,在那些让你紧张的命令前面设一道防线,或者构建一个符合你阅读变更方式的审查视图。

本指南从一个空文件夹构建一个 mod——Token Weather,一个绘制在提示词上方的上下文窗口实时预报。它大约 80 行。随后它会带你浏览两个更大的 mods——Blast Radius 和 Replay Theater,以展示 API 还能做什么。

三者的完整代码位于 anthropics/claude-code-playground。

FIG A Token Weather、Blast Radius 和 Replay Theater,在终端会话中依次展示

FIG 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 都具有相同的形状:CODEJavaScripton("tool.call", { tool: "Bash" }, async ($, e, next) => { // $ 是 mods API:ui、session、state、store、fs、process、clock、http、tool、command、model、... // e 是本次事件的输入,为纯数据// next 将 e 传递给其他插件,然后再传给 Claude Code 自身的行为return next(e); });

Hooks 形成一条链,就像 middleware。你的 hook 先运行,next(e) 将事件交给下一个插件,而在最底部,Claude Code 会执行它本来要做的事情。一个 hook 可以做以下三种事情之一:

Hooks 形成一条链 事件依次经过你的 hook,然后是其他插件,最后是 Claude Code

动作 方式 示例观察 const r = await next(e); /* look */ return r 记录每一次文件编辑。在每回合结束后读取一次数据。重写 return next({ ...e, command: safer }) 改变链条其余部分所看到的内容。

应答 不调用 next 而直接 return { deny: "…" } 拒绝一次工具调用。自行提供一个命令或工具。

这些事件涵盖工具调用、提交时的提示词、回合的开始与结束、会话的开始与结束、slash commands,以及 ui.render:界面中每一块被绘制出来的时刻。该模块运行在属于自己的沙盒中,没有 DOM 也没有 Node,因此它之外的一切都要通过 $。

这与 settings hooks 的区别。settings hook 会为每个事件运行一个 shell 命令,并通过 stdin 和 stdout 传递 JSON。mod 只加载一次并一直留在会话中。

它可以保存状态、绘制随事件发生而更新的 UI,并回调到 Claude Code:打开一个窗格、运行一个进程、注册一个 slash command,或注册一个模型可以调用的工具。

Claude Code 自身也在使用它们。Claude Code 的一些自身功能就是以 mods 的形式构建的,包括 AGENTS.md 支持和对话旁边的 /diff 窗格。

它们的源代码(附带测试)位于公开的 anthropics/claude-code 仓库中的 mods/ 目录下,因此你可以阅读团队是如何构建它们的。

构建你的第一个 MOD:TOKEN WEATHER

Token Weather 读取每个回合结束后上下文窗口的占用程度,并在提示词上方绘制一行:一个天气图标、百分比、窗口内已使用的 tokens 数、近期回合的小图表,以及上一回合增加的量。

已用 预报25% 以下 ☀ Clear 25–49% ☁ Cloudy 50–74% ☂ Showers 75–89% ☇ Storm 90% 及以上 ↯ Compact soon

下面是它在真实会话中的样子。每个回合读取更多文件,条带从 ☀ Clear 填充到 ☂ Showers,再到 ☇ Storm:

FIG C Token Weather 在完整终端会话中跨三个回合的表现:200k 窗口的 18%,然后 67%,然后 81%

捷径:让 Claude 来构建它

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

CODEText为我做一个名为 token-weather 的 Claude Code mod:我的上下文窗口的实时预报,显示在提示词上方的条带中。它应该在一行中显示:

  • 表示上下文窗口占用程度的天气图标和词语:25% 以下 ☀ Clear(黄色)、25–49% ☁ Cloudy(青色)、50–74% ☂ Showers(蓝色)、75–89% ☇ Storm(洋红色)、90% 及以上 ↯ Compact soon(红色)。
  • 已使用的百分比,然后是窗口内已使用的 tokens 数,例如 "134.4k / 200k"。
  • 最近 12 个回合的小图表,用 ▁▂▃▄▅▆▇█ 绘制。
  • 上一回合增加了多少,例如 "▲ +98.3k last turn"。

它应该在每回合后更新。

Claude 会询问一次是否为该会话开启热重载。允许它,当 Claude 的回合结束时,条带就会出现在提示词上方。从那时起,每一次更改都会就地重新加载,因此你可以继续要求调整(“让 Storm 从 70% 开始”、“在末尾加上美元成本”)并看着条带变化。

该 mod 仅在此会话中加载,其文件夹稍后会被清理,所以要保留它,请把该文件夹复制出来,并像安装任何插件一样安装它(步骤 6)。

注意,提示词只描述了你想要看到的内容。编写它并不需要了解 API。Claude Code 内置的 mod 编写指南涵盖了具体做法:状态保存在哪里才能在重载后依然存在、如何用 claude plugin validate 检查插件,以及应挂接哪些事件。

把"What it should show"那几行改掉,它就是你的 mod,而不是我们的。

如果你更想先看看它是如何组装起来的,或者想检查 Claude 写了什么,请继续阅读。

第 1 步:创建文件夹

确认你的 Claude Code 足够新:

CODEShellclaude --version # 2.1.287 or later

创建如下目录结构:

CODETexttoken-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 是一个标准的插件清单:

CODEJSON{ "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 恰好只有一个模块:

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

第 2 步:画点东西

提示词正上方的横条是一个名为 AbovePrompt 的组件。Claude Code 自身不会在那里绘制任何内容,所以它是一个很好的首要目标。挂接它的 ui.render 事件并返回一个元素树:

CODEJavaScript// 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 作为工厂函数。

在加载该插件的情况下启动一个会话:

CODEShellclaude --plugin-dir ./token-weather

"☀ Clear skies" 会出现在提示词上方。保持会话开启。该文件夹处于被监视状态,因此每次保存都会就地重新加载模块,无需重启。这种快速反馈回路正是让编写 mod 变得有趣的主要原因。

提示:一旦你了解了这个结构,就可以像那个快捷方式一样向 Claude 描述下一个 mod。它会把插件写入一个文件夹,该文件夹可在同一会话中热重载。

第 3 步:读取真实数字并将其保存在 $.state 中

$.session.usage() 返回与状态栏相同的数字。context.tokens 是上一次回复所基于的输入,context.window 是模型的窗口,context.percent 是两者相除的结果。这个调用是免费的:只有当你请求细分数据时,它才会发送一个 token 计数请求。

在会话启动时以及每一轮结束后各读取一次数据:

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

这两个钩子都先调用 next(e) 再进行观察。两者都不会改变所发生的事情。

读数应保存在哪里。模块级的 let readings = [] 看似是显而易见的选择,但热重载是一次全新的加载:register 会再次运行,session.start 会再次触发,模块变量会从头开始。因此应把历史记录放进 $.state。它在宿主中为整个会话保存具名值,这些值在重载后依然存在。

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

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

CODETypeScriptexport 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 { … }。

作为回报,你可以免费获得重绘。在渲染钩子运行期间执行的 $.state.get 会让该绘图建立订阅,因此之后每次 $.state.set 都会重绘这个横条。你永远不需要调用 $.ui.invalidate。

第 4 步:绘制预报

下面是完整的模块:

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

有三个细节值得复制到你自己的 mods 中:

组件的 props 位于 e.props 上。hasSurvey 告诉你某个 survey 想要这个 band,因此该 hook 通过 next(e) 让位于它。bodyColumns 是 band 的实际宽度,当有面板停靠在 transcript 旁边时,它比终端更窄。请据此调整树的大小。

只有 e.component、e.surface、e.requestId 和 e.viewport 位于 e 的顶层。

没有内容可绘制时就 pass。返回 next(e) 会把 band 交还给 Claude Code 和其他 mods。

使用单宽度符号,而不是 emoji。☀ ☁ ☂ ☇ ↯ 在每种终端字体中都能对齐。

保存文件后,正在运行的 session 会加载它。经过几轮读取大文件之后,band 会从 Clear 变为 Showers、再到 Storm,如本节开头的录屏所示。

第 5 步:验证与测试

claude plugin validate 会以与 Claude Code 相同的方式读取 manifest 和模块源码,并报告该模块 hook 了什么、调用了什么:

CODEText$ 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 本应给出的回答进行 stub,因此你可以精确控制 $.session.usage() 的返回值:

CODETypeScript// 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 $.unmount(); }); });

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

该测试还检查了第 3 步中的重绘行为。band 会在 turn.complete 之后更新,而 mod 从未请求过重绘。

mod 就是一个 plugin,所以它的分发方式也相同。把它放入一个 marketplace,这个 marketplace 可以简单到只是一个包含 .claude-plugin/marketplace.json 的文件夹:

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

CODEShellclaude 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 plugin,因此你可以像分享任何其他 plugin 一样分享它,没有什么新东西要学。把 mod 放进一个带 marketplace 文件的 GitHub repo,这个 repo 就成了你的 marketplace。

任何人都可以从中安装,你也可以用一次普通的 push 来更新它。

在 Claude Code 中安装需要三条命令:

CODEText/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 会介入事件、打开窗格并接受输入。

两者的完整代码都在 anthropics/claude-code-playground 中:Blast Radius 和 Replay Theater,各自附带一份说明其构建方式的 README。

Blast Radius:在危险命令运行之前看到它会改动什么

当 Claude 通过 Bash 调用 rm -rf、git reset --hard、git clean、force push 或数据库迁移时,Blast Radius 会扣住这次调用。它会算出该命令将触及哪些内容,并打开一个带有 Proceed 和 Cancel 的窗格。

按 2,Claude 会收到一个附带原因的拒绝。按 1,命令按原样运行。

图 D Blast Radius 扣住了 rm -rf build,并列出它将删除的 9 个文件(1.1 MB)。Cancel 拒绝了它;第二次尝试时,Proceed 将其运行。

它使用了三个钩子:Bash 上的 tool.call,以及 Pane 和 AbovePrompt 上的 ui.render。其核心就是上表中的 "answer" 一招:

CODEJavaScripton("tool.call", { tool: "Bash" }, async ($, e, next) => { const risk = classify(String(e.command ?? "")); if (risk === null) return next(e); // everything else runs as normal 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"; // too narrow for a pane: draw above the prompt while (held.decision === null && !next.signal.aborted) { await $.process.run(["sleep", "0.25"]); // time inside $ calls doesn't count against the hook's time limit } if (held.decision === "proceed") return next(e); // let it run 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 设置了决定;当 next.signal 中止时(你按了 Esc),它就会放弃。

带热键的按钮。Button({ label: "Proceed", hotkey: "1", onPress }) 可以通过鼠标点击、Tab 加 Enter,或者直接按数字键来使用。

降级到 band。终端足够宽时,会把窗格停靠在对话记录旁边。当 $.ui.open 返回 isPlaced: false 时,同样的报告会绘制在提示符上方:

图 E 在 120 列时,Blast Radius 在提示符上方的 band 中绘制它对 git reset --hard 的报告

它是一张安全网,而不是权限系统。它读取的是命令文本,所以 $(…)、别名以及调用 rm 的脚本都能绕过它。想要硬性拦截,请使用权限规则。

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

在一轮运行期间,Replay Theater 会记录每一次 Edit 和 Write 调用:文件,以及改动前后的文本。这一轮结束时,提示符上方会出现一条提示。按 r(或输入 /replay),一个窗格会每次一个 diff 地带你走一遍这些编辑,带有一条编号的步骤条以及 Prev、Next 和 Close 按钮。

图 F Replay Theater:跨 3 个文件、共 5 次编辑的重命名之后的提示,随后是窗格中的第 1 至 5 步

它从不阻塞或更改编辑。它只是观察:

CODEJavaScripton("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 把这些编辑归拢为每轮一次 replay,而 e.agentId 会把 subagent 的轮次排除在分组之外。

注册一个斜杠命令。在 session.start 中调用 $.command.register,然后在 command.run 上应答它。

读取文件。对于 Write,$.fs.read 会在写入落地之前取得旧内容,因此 diff 是真实的。

摆放位置是 surface 的职责。全屏时窗格停靠在右侧。在 80 列时,它会在提示符上方内联打开。无论哪种方式,mod 绘制的都是同一棵树。

图 G Replay Theater 在 80 列下的样子,内联绘制在提示符上方

值得保持的四个习惯

依靠 Claude Code 为你写好的类型。每次加载你的 mod 时,Claude Code 都会把针对你这次构建的声明写入该 mod 的 .claude-plugin/types/ 文件夹,因此你的编辑器和 tsc -p 无需任何额外步骤即可工作。

它们是每个事件、$ 上的每个方法以及每个元素 props 的参考资料。

从 e.props 读取 props。hasSurvey、bodyColumns 以及其余的都存放在那里,而不在 e 本身上。

为热重载做好准备。每次保存都会重新运行 register 和 session.start,所以要把数据保存在 $.state 中,而不是模块变量里。

当某个绘图没有显示出来时,去读日志。运行 claude --debug,寻找那条写着某个 hook 返回了无法通过校验的树的行。

你会去 mod 什么?

这里的三个 mod 各自源于一个问题:我的 context 有多满?、这条命令即将删除什么?、以及 Claude 刚刚改了什么?你的问题会不一样,而这正是重点所在。以下是一些可以入手的思路:

一个基于 $.session.usage() 的成本或速率限制(rate-limit)计量器,用 $.ui.status 做成状态栏

一个 prompt.submit hook,把你们团队的规范添加到每一条 prompt 中

一个列出 Claude 本次会话读取过的文件的面板,作为它所见内容的实时地图

一个专注计时器,在一轮长回合结束时用 $.ui.toast 发送一条 toast 提示

一个针对你的技术栈调校的 tool.call 守卫,例如生产环境的 kubectl contexts 或 terraform apply

做出了一个如今每天都在用的 mod?把它发布到 X 或 LinkedIn 上,并附上一张 GIF 或它运行时的截图,让其他开发者看到可以做到什么。把这个 plugin 放进某个 marketplace(Sharing your mod)并附上链接,这样任何喜欢它的人都能用三条命令完成安装。

展开原文提取正文(采集端自动提取,可能有缺失)

A mod is a small JavaScript or TypeScript file that runs inside your Claude Code session. It can watch what's happening, change what Claude Code does, or draw its own UI, in the terminal or the desktop app. You don't need to learn the API to try one. Run claude, then describe the mod you want. Allow hot reload when it asks, and the mod shows up when the turn ends. Claude Code already lets you change a lot about how it behaves: settings, permission rules, slash commands, skills and a status line. Mods go further: they can rewrite or replace what Claude Code does, and draw custom UI. Under the hood, mods are hooks that ship inside plugins, and each one sees every event in your session as it happens. That makes mods a way to fit Claude Code to how you work. You can add a readout you check all the time, put a guard in front of the commands that make you nervous, or build a review view for how you like to read changes. This guide builds one mod from an empty folder, Token Weather, a live forecast of the context window drawn above the prompt. It's about 80 lines. Then it tours two larger mods, Blast Radius and Replay Theater, to show what else the API can do. The finished code for all three is in anthropics/claude-code-playground. FIG AToken Weather, Blast Radius and Replay Theater, one after another, in a terminal session FIG BToken Weather's band as the context window fills: Clear at 18%, Showers at 67%, Storm at 81% Claude Code 2.1.287 or later. Mods are on by default, so there's nothing to turn on. The API can change between releases. Each time Claude Code loads a mod, it writes the type declarations for your build into the mod's .claude-plugin/types/ folder, and those are the authority for your version. HOW A MOD WORKS A mod is a Claude Code plugin whose behavior lives in a JavaScript or TypeScript module:

The folder is a normal plugin, with a .claude-plugin/plugin.json manifest. hooks/hooks.json names one module under modules. The module exports register(on, options). Inside it, on(event, matcher?, hook) adds a hook.

Every hook has the same shape: CODEJavaScripton("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); }); Hooks form a chain, like middleware. Yours runs, next(e) hands the event to the next plugin, and at the bottom Claude Code does what it would have done anyway. A hook can do one of three things: Hooks form a chainAn event passes through your hook, then other plugins, then Claude Code MoveHowExampleObserveconst r = await next(e); /* look */ return rRecord every file edit. Take a reading after each turn.Rewritereturn next({ ...e, command: safer })Change what the rest of the chain sees.Answerreturn { deny: "…" } without calling nextRefuse a tool call. Serve a command or a tool yourself. The events cover tool calls, the prompt as submitted, turns starting and finishing, the session starting and ending, slash commands, and ui.render: every piece of the interface as it's drawn. The module runs in a sandbox of its own, with no DOM and no Node, so everything outside it goes through $. How this differs from settings hooks. A settings hook runs a shell command for each event and passes JSON over stdin and stdout. A mod is loaded once and stays in the session. It can keep state, draw UI that updates as events happen, and call back into Claude Code: open a pane, run a process, register a slash command, or register a tool the model can call. Claude Code uses them itself. Some of Claude Code's own features are built as mods, including AGENTS.md support and the /diff pane beside the conversation. Their source, with tests, is in the public anthropics/claude-code repository under mods/, so you can read how the team builds them. BUILD YOUR FIRST MOD: TOKEN WEATHER Token Weather reads how full the context window is after each turn and draws one line above the prompt: a weather icon, the percentage, the tokens used out of the window, a small chart of recent turns, and how much the last turn added. UsedForecastunder 25%☀ Clear25–49%☁ Cloudy50–74%☂ Showers75–89%☇ Storm90% and up↯ Compact soon Here it is in a real session. Each turn reads more files, and the band fills from ☀ Clear to ☂ Showers to ☇ Storm: FIG CToken Weather in a full terminal session over three turns: 18%, then 67%, then 81% of a 200k window The shortcut: let Claude build it You can skip the six steps. Claude Code knows how to write mods, so you can describe the one you want and let it do the work. Start a session with claude and paste the prompt below: CODETextMake me a Claude Code mod called token-weather: a live forecast of my context window, shown in the band above the prompt.

What it should show, on one line:

  • A weather icon and word for how full the context window is: under 25% ☀ Clear (yellow), 25–49% ☁ Cloudy (cyan), 50–74% ☂ Showers (blue), 75–89% ☇ Storm (magenta), 90% and up ↯ Compact soon (red).
  • The percentage used, then the tokens used out of the window, like "134.4k / 200k".
  • A small chart of the last 12 turns, drawn with ▁▂▃▄▅▆▇█.
  • How much the last turn added, like "▲ +98.3k last turn".

It should update after every turn. Claude asks once whether to turn on hot reloading for the session. Allow it, and the band appears above the prompt when Claude's turn ends. From then on, every change reloads in place, so you can keep asking for tweaks ("make Storm start at 70%", "add the dollar cost at the end") and watch the band change. The mod loads only in this session, and its folder is cleaned up later, so to keep it, copy the folder out and install it like any plugin (Step 6). Notice that the prompt only describes what you want to see. You don't need to know the API to write one. Claude Code's built-in guide for writing mods covers the how: where to keep state so it survives a reload, how to check the plugin with claude plugin validate, and which events to hook. Change the "What it should show" lines and it's your mod, not ours. If you'd rather see how it's put together first, or want to check what Claude wrote, read on. Step 1: Create the folder Check that your Claude Code is new enough: CODEShellclaude --version # 2.1.287 or later Create this layout: CODETexttoken-weather/ ├── .claude-plugin/ │ ├── plugin.json │ └── types/ (written by Claude Code when it loads the mod) ├── hooks/ │ ├── hooks.json │ └── token-weather.mjs ├── types/ │ └── index.d.ts (added in step 3) └── tests/ └── token-weather.test.ts (added in step 5) .claude-plugin/plugin.json is a standard plugin manifest: CODEJSON{ "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 points at the module. A mod has exactly one: CODEJSON{ "modules": ["./token-weather.mjs"] } Step 2: Draw something The band directly above the prompt is a component called AbovePrompt. Claude Code draws nothing there itself, so it's a good first target. Hook its ui.render event and return a tree of elements: CODEJavaScript// 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" })], }); }); } The elements aren't globals. $.ui.resolve(e) returns the constructors for the surface being drawn, because each surface Claude Code draws on supports a slightly different set. JSX works too, with h as the factory. Start a session with the plugin loaded: CODEShellclaude --plugin-dir ./token-weather "☀ Clear skies" appears above the prompt. Keep the session open. The folder is watched, so every save reloads the module in place, with no restart. That quick feedback loop is most of what makes mods fun to write. Tip: Once you know the shape, describe the next mod to Claude the way the shortcut does. It writes the plugin to a folder that hot-reloads in the same session. Step 3: Read real numbers and keep them in $.state $.session.usage() returns the same figures as the status line. context.tokens is the input the last response was answered over, context.window is the model's window, and context.percent is one over the other. The call is free: it only sends a token-count request if you ask for a breakdown. Take a reading when the session starts and after every turn: CODEJavaScripton("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; }); Both hooks call next(e) first and then observe. Neither one changes what happens. Where to keep the readings. A module-level let readings = [] looks like the obvious choice, but a hot reload is a fresh load: register runs again, session.start fires again, and module variables start over. Put the history in $.state instead. It holds named values in the host for the whole session, and they survive reloads. CODEJavaScript// 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)); } A state value is declared in the plugin's type contract, which is a small .d.ts file the manifest points to. Add types/index.d.ts: CODETypeScriptexport type TokenWeatherReading = { tokens: number; window: number; percent: number };

declare module "claude-code" { interface PluginState { "token-weather": { readings: TokenWeatherReading[] }; } } Then add "types": "./types/index.d.ts" to plugin.json. If you skip this step, claude plugin validate stops you with an error that names the fix: token-weather.readings is not declared: the manifest's types contract must name it in interface PluginState { … }. In return, you get redraws for free. A $.state.get made while a render hook runs subscribes that drawing, so every later $.state.set redraws the band. You never call $.ui.invalidate. Step 4: Draw the forecast Here is the whole module: CODEJavaScript// 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); } Three details are worth copying into your own mods:

The component's props are on e.props. hasSurvey tells you a survey wants the band, so the hook yields to it with next(e). bodyColumns is the band's real width, which is narrower than the terminal while a pane is docked beside the transcript. Size the tree to it. Only e.component, e.surface, e.requestId and e.viewport sit at the top level of e. Pass when you have nothing to draw. Returning next(e) gives the band back to Claude Code and to other mods. Use single-width symbols, not emoji. ☀ ☁ ☂ ☇ ↯ line up in every terminal font.

Save the file and the running session picks it up. After a few turns that read large files, the band moves from Clear to Showers to Storm, as in the recording at the start of this section. Step 5: Validate and test claude plugin validate reads the manifest and the module's source the same way Claude Code will, and reports what the module hooks and calls: CODEText$ 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 runs the plugin's *.test.ts files against the real Claude Code runtime. Hooks that a test registers with on run after the mod in the chain and stub what Claude Code would answer, so you control exactly what $.session.usage() returns: CODETypeScript// 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(); }); }); CODEText$ claude plugin test ./token-weather (pass) token-weather > the band follows the context window 1 pass 0 fail The test also checks the redraw behavior from step 3. The band updates after turn.complete without the mod ever asking for a redraw.

A mod is a plugin, so it ships the same way. Put it in a marketplace, which can be as simple as a folder with a .claude-plugin/marketplace.json: CODEJSON{ "name": "my-mods", "owner": { "name": "You" }, "plugins": [{ "name": "token-weather", "source": "./token-weather" }] } CODEShellclaude plugin marketplace add ./my-mods claude plugin install token-weather@my-mods --scope user To compare your version with a finished one, see the complete Token Weather mod in anthropics/claude-code-playground. SHARING YOUR MOD A mod is a Claude Code plugin, so you share it the same way as any other plugin, and there's nothing new to learn. Put the mod in a GitHub repo with a marketplace file and that repo becomes your marketplace. Anyone can install from it, and you can update it with a normal push. Installing takes three commands in Claude Code: CODEText/plugin marketplace add your-org/my-mods /plugin install token-weather@my-mods /reload-plugins The mod starts when you reload. If it doesn't show up, restart Claude Code. A mod is code that runs inside Claude Code on your machine, with the same access Claude Code has, and it's written by its publisher, not Anthropic. So install mods the way you'd install a package: read the repo first and only install from people you trust. Nothing gets installed until you run the command. The Claude directory accepts plugins that include mods, and you can submit yours at claude.ai/directory/manage, so people can find it without a link from you. TWO MORE MODS Token Weather only watches and draws. The next two mods step into events, open panes, and take input. The complete code for both is in anthropics/claude-code-playground: Blast Radius and Replay Theater, each with a README that explains how it was built. Blast Radius: see what a risky command would change before it runs When Claude calls Bash with rm -rf, git reset --hard, git clean, a force push, or a database migration, Blast Radius holds the call. It works out what the command would touch and opens a pane with Proceed and Cancel. Press 2 and Claude gets a refusal with the reason. Press 1 and the command runs as written. FIG DBlast Radius holds rm -rf build and lists the 9 files (1.1 MB) it would delete. Cancel refuses it; on the second try, Proceed runs it. It uses three hooks: tool.call on Bash, and ui.render on Pane and on AbovePrompt. The core of it is the "answer" move from the table above: CODEJavaScripton("tool.call", { tool: "Bash" }, async ($, e, next) => { const risk = classify(String(e.command ?? "")); if (risk === null) return next(e); // everything else runs as normal

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"; // too narrow for a pane: draw above the prompt

while (held.decision === null && !next.signal.aborted) { await $.process.run(["sleep", "0.25"]); // time inside $ calls doesn't count against the hook's time limit } if (held.decision === "proceed") return next(e); // let it run return { deny: Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}. }; }); What it teaches:

$.process.run for dry runs. The report comes from the tools' own commands: git status --porcelain, git clean -n, git log HEAD..origin/main, showmigrations. Arguments go in as an argv array, so nothing in a path is run as shell code. Holding a call. A hook gets 10 seconds of its own time per dispatch, but time spent waiting inside a $ call doesn't count. The loop waits on short sleep processes until a button's onPress sets the decision, and it gives up when next.signal aborts (you pressed Esc). Buttons with hotkeys. Button({ label: "Proceed", hotkey: "1", onPress }) works by click, by Tab and Enter, or by the digit. Degrade to the band. The terminal docks a pane beside the transcript when it's wide enough. When $.ui.open answers isPlaced: false, the same report is drawn above the prompt:

FIG EAt 120 columns, Blast Radius draws its report for git reset --hard in the band above the prompt It's a safety net, not a permission system. It reads the command text, so $(…), aliases and scripts that call rm get past it. Use permission rules for a hard block. Replay Theater: step through the last turn's edits While a turn runs, Replay Theater records every Edit and Write call: the file, plus the text before and after. When the turn ends, a hint appears above the prompt. Press r (or type /replay) and a pane walks through the edits one diff at a time, with a strip of numbered steps and Prev, Next and Close buttons. FIG FReplay Theater: the hint after a 5-edit rename across 3 files, then steps 1 to 5 in a pane It never blocks or changes an edit. It observes: CODEJavaScripton("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" })); What it teaches:

Pairing events. turn.start and turn.complete bracket the edits into one replay per turn, and e.agentId keeps subagent turns out of the grouping. Registering a slash command. $.command.register in session.start, then answer it on command.run. Reading files. For a Write, $.fs.read gets the old contents just before the write lands, so the diff is real. Placement is the surface's job. In fullscreen the pane docks on the right. At 80 columns it opens inline above the prompt. The mod draws the same tree either way.

FIG GReplay Theater at 80 columns, drawn inline above the prompt FOUR HABITS WORTH KEEPING

Lean on the types Claude Code writes for you. Each time it loads your mod, Claude Code writes the declarations for your build into the mod's .claude-plugin/types/ folder, so your editor and tsc -p work with no extra step. They're the reference for every event, every method on $ and every element's props. Read props from e.props. hasSurvey, bodyColumns and the rest live there, not on e itself. Plan for hot reload. Every save runs register and session.start again, so keep data in $.state, not in module variables. When a drawing doesn't show, read the log. Run claude --debug and look for a line saying a hook returned a tree that does not validate.

WHAT WILL YOU MOD? The three mods here came from one question each: how full is my context?, what is this command about to delete?, and what did Claude just change? Your questions will be different, and that's the point. Some ideas to start from:

A cost or rate-limit meter from $.session.usage(), as a status line with $.ui.status A prompt.submit hook that adds your team's conventions to every prompt A pane that lists the files Claude read this session, as a live map of what it has seen A focus timer that sends a toast with $.ui.toast when a long turn finishes A tool.call guard tuned to your stack, such as production kubectl contexts or terraform apply

Made a mod you now use every day? Post it on X or LinkedIn with a GIF or a screenshot of it running, so other developers can see what's possible. Put the plugin in a marketplace (Sharing your mod) and link to it, so anyone who likes it can install it with three commands.

原文入口

阅读原文(claude.dev)

原文将在新窗口打开。收录时间不是内容更新时间;正文、译文与解读的版权归原作者/来源所有,本站仅作个人阅读辅助并保留原文入口;页面时间均为北京时间。

作者/署名来自原文结构化字段(Atom/RSS/HTML meta/JSON-LD;GitHub release 显示的是发布者账号),未经模型补充。

了解来源批准与收录规则