教程Claude Code系列第 3 / 11 篇

从零写第一个 Claude Code mod:在输入框上方显示上下文用了多少

跟着官方教程的思路,从一个空文件夹写出一个 Claude Code mod:在输入框上方实时显示上下文窗口的占用。讲清目录结构、怎么画界面、数据为什么要放在 $.state、怎么校验和测试、怎么分享。

发布 · 约 10 分钟读完 · 经人工审核()· 本站的内容怎么来的

本文根据公开分享整理,本站尚未实测。操作前请对照文末的原始来源。

本页目录(14 节)
  1. 你会做出什么
  2. 准备
  3. 捷径:直接让 Claude 写
  4. 先理解三件事
  5. 第一步:建文件夹
  6. 第二步:先画出一行字
  7. 第三步:读取真实的数字,存进 $.state
  8. 第四步:把预报画出来
  9. 第五步:校验和测试
  10. 第六步:安装和分享
  11. 再进一步:另外两个示例教了什么
  12. 四个值得养成的习惯
  13. 可以拿来练手的点子
  14. 最后提醒一次安全

这篇教程解决一个问题:自己动手写一个 mod,让 Claude Code 在输入框上方显示一行你关心的信息。 例子用的是官方教程里的 Token Weather:把上下文窗口的占用画成一行“天气预报”。

Mods 是什么、和 hooks 与 skills 有什么区别、安装别人的 mod 要注意什么,见本站的 Claude Code Mods 是什么。这篇只讲怎么写。

你会做出什么

每一轮对话结束后,输入框上方的那一行会更新,显示:

  • 一个天气图标和一个词,表示上下文用了多少;
  • 已用的百分比,以及已用 token 数和窗口大小;
  • 最近几轮的一个小柱状图;
  • 上一轮增加了多少。
已用 显示
25% 以下 ☀ Clear
25% 到 49% ☁ Cloudy
50% 到 74% ☂ Showers
75% 到 89% ☇ Storm
90% 及以上 ↯ Compact soon

完整的代码大约 80 行。

准备

需要 Claude Code 2.1.287 或更高版本,Mods 默认开启,不用另外打开:

claude --version

官方提醒:Mods 的 API 在不同版本之间可能变化。每次加载 mod 时,Claude Code 会把当前版本的类型声明写进 mod 的 .claude-plugin/types/ 文件夹,以那里的内容为准。

捷径:直接让 Claude 写

你不需要先学 API。启动 claude,把想要的效果描述给它就行。官方给的提示词大意是:

Make 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.
- The percentage used, then the tokens used out of the window.
- A small chart of the last 12 turns.
- How much the last turn added.

It should update after every turn.

之后会发生的事:

  1. Claude 问一次是否为这个会话打开热加载,选允许。
  2. 这一轮结束时,那一行就出现在输入框上方。
  3. 之后每次修改都会原地重新加载,可以继续提要求,比如“make Storm start at 70%”,看着它变。

要注意:这样做出来的 mod 只在当前会话里加载,它的文件夹之后会被清理。 想留下来,要把文件夹复制出来,再像普通插件一样安装(见第六步)。

提示词里只描述了想看到什么,没有写怎么实现。把“显示什么”那几行换掉,就是你自己的 mod。

想知道它是怎么搭起来的,或者想检查 Claude 写的代码,继续往下看。

先理解三件事

一个 mod 就是一个插件。 它的行为写在一个 JavaScript 或 TypeScript 模块里。模块导出一个 register(on) 函数,在里面用 on(事件, 匹配条件, 处理函数) 登记要处理的事件。

每个处理函数都是同一个形状:

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  // $    Mods API:界面、会话、状态、文件、进程、网络等都通过它
  // e    这次事件的数据
  // next 把事件交给其他插件,最后交给 Claude Code 自己
  return next(e);
});

处理函数像中间件一样串成一条链,对一个事件可以做三种事:

做法 怎么写 例子
观察 const r = await next(e); return r 记录每一次文件修改;每轮结束后读一次数
改写 return next({ ...e, command: safer }) 改掉后面的环节看到的内容
接管 不调用 next,直接 return { deny: "…" } 拒绝一次工具调用;自己实现一个命令

模块运行在自己的沙箱里,没有 DOM,也没有 Node 的接口,所以要碰外面的任何东西都得通过 $。

1建文件夹

token-weather/
├── .claude-plugin/
│   ├── plugin.json
│   └── types/            Claude Code 加载 mod 时自动写入
├── hooks/
│   ├── hooks.json
│   └── token-weather.mjs
├── types/
│   └── index.d.ts        第三步添加
└── tests/
    └── token-weather.test.ts   第五步添加

.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" }
}

hooks/hooks.json 指向模块文件。一个 mod 只有一个模块:

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

2先画出一行字

输入框正上方那一行是一个叫 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" })],
    });
  });
}

Box 和 Text 不是全局变量,要用 $.ui.resolve(e) 取出来,因为 Claude Code 的不同界面支持的元素略有不同。

带着这个插件启动一个会话:

claude --plugin-dir ./token-weather

输入框上方会出现“☀ Clear skies”。这个会话不要关。 文件夹是被监视的,之后每次保存文件,模块都会原地重新加载,不用重启。

3读取真实的数字,存进 $.state

$.session.usage() 返回的数字和状态栏里的一样:context.tokens 是已用的 token,context.window 是模型的窗口大小,context.percent 是两者的比值。

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

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) 再观察,不改变原本的行为。

读到的数字放在哪里,是写 mod 最容易踩的坑。 直觉是在模块里写一个 let readings = [],但热加载等于重新加载一遍:register 会再运行一次,session.start 会再触发一次,模块里的变量全部重来。

正确的做法是放进 $.state。它把命名的值保存在 Claude Code 那一侧,整个会话期间都在,热加载之后也在:

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

用 $.state 还要多做一件事:在插件的类型声明里登记这个值。新建 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"。漏了这一步,校验时会报错,并告诉你怎么改。

这样做还有一个好处:界面会自动重画。 在画界面的处理函数里调用过 $.state.get,就等于订阅了这个值,之后每次 $.state.set 都会让那一行重画,不需要自己去通知。

4把预报画出来

把第二步里写死的那行字,换成根据读数算出来的内容。处理函数变成这样:

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

band 函数做的事不复杂:取最新一次读数,按上面那张表选出图标、文字和颜色,拼出百分比和 token 数;宽度够的时候,再加上最近几轮的柱状图和上一轮的增量。完整的写法见官方教程,以及 GitHub 上 anthropics/claude-code-playground 仓库里的 Token Weather,这里不整段照搬。

官方教程特别指出三个细节,值得带到你自己的 mod 里:

  • 组件的属性在 e.props 上,不在 e 上。 hasSurvey 表示有问卷要用这一行,这时用 next(e) 让出来;bodyColumns 是这一行实际的宽度,旁边停靠了面板时它比终端窄,要按它来排版。
  • 没东西可画时就放行。 返回 next(e),把这一行还给 Claude Code 和其他 mod。
  • 用单字符宽度的符号,不要用 emoji。 ☀ ☁ ☂ ☇ ↯ 在各种终端字体里都能对齐。

保存文件,开着的会话会自动加载。让 Claude 读几个大文件,那一行就会从 Clear 变成 Showers,再变成 Storm。

5校验和测试

claude plugin validate 用和 Claude Code 相同的方式读取清单和模块源码,报告这个模块处理哪些事件、调用哪些能力:

$ 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

这个命令对别人的 mod 同样有用:安装之前先跑一遍,就知道它会监听什么、能做什么。

claude plugin test 在真实的 Claude Code 运行环境里执行插件的 *.test.ts 文件。测试里可以替 Claude Code 给出假的回答,所以能精确控制 $.session.usage() 返回什么,再断言那一行显示了对应的文字。测试文件的完整写法见官方教程和仓库。

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

6安装和分享

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

想给别人用,就把它放进一个 GitHub 仓库,这个仓库就成了你的 marketplace。别人在 Claude Code 里三条命令装上:

/plugin marketplace add your-org/my-mods
/plugin install token-weather@my-mods
/reload-plugins

重新加载后 mod 开始运行;没出现的话,重启 Claude Code。

再进一步:另外两个示例教了什么

Token Weather 只是观察和画界面。官方仓库里还有两个更大的示例,各展示了一种做法:

Blast Radius:高风险命令执行前先拦住。 Claude 要执行 rm -rf、git reset --hard、强制推送这类命令时,它先把调用扣住,算出会影响什么,打开一个面板让你选继续或取消。它展示的是“接管”:不调用 next,返回 { deny: "…" },Claude 就会收到拒绝和原因。能学到的几点:

  • 用 $.process.run 做试运行,参数以数组形式传入,路径里的内容不会被当成 shell 代码执行;
  • 一个处理函数每次有 10 秒的时间,但等待 $ 调用的时间不计入,所以可以等用户按键;
  • 按钮可以带快捷键;终端太窄放不下面板时,退而在输入框上方显示。

官方也说明了它的定位:它是一张安全网,不是权限系统。 它只看命令的文字,$(…)、别名、调用 rm 的脚本都能绕过它。要硬性禁止,用权限规则。

Replay Theater:回放上一轮的修改。 它在一轮进行中记下每一次 Edit 和 Write 的前后内容,这一轮结束后,按一个键就能逐个查看差异。它从不拦截或修改,只是观察。能学到的几点:

  • 用 turn.start 和 turn.complete 把一轮里的修改归成一组,用 e.agentId 排除子代理;
  • 在 session.start 里用 $.command.register 注册一个斜杠命令,再在 command.run 里响应它;
  • 用 $.fs.read 在写入之前读出旧内容,得到真实的差异。

四个值得养成的习惯

  1. 依靠 Claude Code 写给你的类型声明。 它们在 .claude-plugin/types/ 里,是每个事件、$ 上每个方法、每个元素属性的权威参考。
  2. 属性从 e.props 读。
  3. 按热加载来设计。 每次保存都会重新运行 register 和 session.start,数据放 $.state,不放模块变量。
  4. 画的东西没出现,就看日志。 用 claude --debug 启动,找一行说“处理函数返回的内容没有通过校验”的记录。

可以拿来练手的点子

官方教程列的几个:

  • 用 $.session.usage() 做一个花费或限额的显示;
  • 处理 prompt.submit,给每条提示词加上团队的约定;
  • 一个面板,列出这次会话里 Claude 读过的文件;
  • 一个专注计时器,长任务结束时弹一条提示;
  • 针对你自己技术栈的 tool.call 防护,比如生产环境的 kubectl 或 terraform apply。

最后提醒一次安全

mod 是在你电脑上、以 Claude Code 同样的权限运行的代码,由发布者编写,不是 Anthropic 写的。安装别人的 mod 要像安装软件包一样:先看仓库,只装信任的人写的。细节见 Claude Code Mods 是什么 里的安全一节。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章