自定义斜杠命令速查:/cmds 打开面板,按你配的分组列出 ~/.claude/commands 和项目 .claude/commands 里的命令、参数和用途;空闲时提示行末尾轮换介绍一条

五个 Claude Code mod:截图面板 shot-view、长任务进度 task-eta、按目录精简 MCP 说明 ctx-slim、自定义命令速查 cmds、等你拍板的待定面板 decide。前三个各在输入框上方画一行,颜色分别是蓝、紫、绿,不再用 Claude Code 自带的橙色 ⚠ 状态行;cmds 用 /cmds 打开速查面板,空闲时在提示行末尾介绍一条命令;decide 在 Claude 登记了要你定的事时在右上角弹出提醒,/decide 打开面板作答。

77 秒介绍视频(点图下载 mp4,13.5 MB):
mod 是 Claude Code v2.1.287 引入的一种插件:插件里放一个 JavaScript / TypeScript 模块,注册一组事件处理函数,在 Claude Code 进程里运行。它能在输入框上方的条带和侧边面板里画界面、注册斜杠命令、在工具调用前后插一手;其中画界面只有 mod 能做,settings 里的 hook、skill 和 MCP 都不行。v2.1.287 起默认开启。官方文档:<https://code.claude.com/docs/en/plugins/mods/overview>
需要 Claude Code 2.1.287 或更新版本。
claude plugin marketplace add Bearisbug/cc-mods
claude plugin install shot-view@cc-mods
claude plugin install task-eta@cc-mods
claude plugin install ctx-slim@cc-mods
claude plugin install cmds@cc-mods
claude plugin install decide@cc-mods
已经开着的会话要运行 /reload-plugins 或新开会话才会加载。
两个面板也可以用快捷键打开。在 ~/.claude/keybindings.json 里加上(command:<名字> 等于输入对应的斜杠命令,只能放在 Chat 上下文):
{
"bindings": [
{
"context": "Chat",
"bindings": { "ctrl+x s": "command:shots", "ctrl+x p": "command:steps" }
}
]
}
不配快捷键时,输入 /shots、/steps 效果相同。

xcrun simctl io booted screenshot、Playwright 截图),这张图就进入列表,最多保留最近 60 张。.png。/shots(ctrl+x s)打开右侧面板,Claude 正在工作时也能立刻打开。面板只显示未读的截图,打开时停在最新一张,顶行写着当前是第几张未读、已读几张。n 下一张(更早的),p 上一张(更新的),r 标为已读(看过、不用再看时按,只是翻过不算),u 撤销最近一次已读并跳回那一张,按住 o 在面板里放大当前这张、松开收起,轻按 o 用 macOS 快速查看打开,f 在 Finder 里显示,y 复制路径;Esc 或再按一次 ctrl+x s 关闭,面板开着时 Esc 只关面板,不会中断 Claude。o 是靠终端在按住时连续补发的重复按键来识别的,mod 收不到「松开」事件。有的终端按住字母时会弹出 macOS 的重音字符菜单,不补发重复按键,这时按住会被当成轻按,打开快速查看;对这个终端运行 defaults write <终端的 bundle id> ApplePressAndHoldEnabled -bool false 再重开,就会改回按住连发。面板内放大受面板宽度限制:横屏截图放大后几乎一样大,竖长的手机截图会明显变大;想看大图就轻按 o。~/.claude/settings.json 的 env 里加 "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"。终端不支持时面板只显示文件名,轻按 o 用快速查看看。sips,快速查看靠 qlmanage,在 Finder 里显示靠 open。
/steps(ctrl+x p)展开「任务步骤」面板:每一步用 ✓ / ▶ / ○ 标状态,「–」表示 Claude 核对后认为这一步后来不需要了;每一步写着实际用时、预计用时和调用次数,当前步骤下面显示 Claude 正在执行的命令。面板里按 c 让 Claude 重新核对;一轮已经结束、清单还没完成时,按 d 把整份清单标为完成。/clear 会清掉清单。$.model.fork 发请求,每轮最多 6 次;一轮结束时的核对不受这个次数限制,失败后隔 5 秒、10 秒各重试一次。请求复用会话的 prompt cache,但仍然计入你的用量。
prompt.attachment 上拦下 MCP 说明,按你配的规则去掉当前目录用不上的服务器那一节;工具本身照常可用。规则可以在 /config 里的「MCP 说明保留规则」填一行,或者运行 /plugin configure ctx-slim@cc-mods,也可以直接写进 ~/.claude/settings.json:
{
"pluginConfigs": {
"ctx-slim@cc-mods": {
"options": {
"rules": "kando: path~kando; synco: text=<!-- synco:project-context:start | path~synco; shadcn-io: file=package.json"
}
}
}
}
规则格式是 服务器: 条件 | 条件; 服务器: 条件,多条规则用分号或换行隔开:
| 写法 | 含义 |
|---|---|
| 服务器名 | MCP 说明里 ## 后面的名字,不区分大小写 |
path~子串 | 当前目录路径里包含这个子串,不区分大小写 |
file=文件名 | 从当前目录往上能找到这个文件;当前目录在 HOME 下时只找到 HOME 为止 |
text=文字 | 当前目录及上级目录的 CLAUDE.md 或 AGENTS.md 里含有这段文字 |
列出的服务器只要有一个条件成立就保留它的说明,否则省略;没列出的服务器一律保留。条件里不能出现 ; 和 |,写错的部分会被跳过,不会因此误删说明。
上面那段示例是作者自己的规则:kando 的说明只在路径含 kando 时保留,synco 的说明在 CLAUDE.md 里有 Synco 的 project-context 标记、或路径含 synco 时保留,shadcn-io 的说明只在能找到 package.json 的前端项目里保留。
/cmds 打开速查面板,再输一次关掉;Claude 回答到一半时输入也立刻生效,不用等这一轮结束。面板里列出 ~/.claude/commands/(设了 CLAUDE_CONFIG_DIR 时是它下面的 commands/)和当前项目 .claude/commands/ 里的命令:第一行是命令名和 frontmatter 里的 argument-hint,第二行是用途,取 description 在括号外第一个冒号或分号之前的那段。同名命令以项目里的为准。.md 文件,子目录里的命令不列。分组可以在 /config 里的「短命令分组」填一行,或者运行 /plugin configure cmds@cc-mods,也可以直接写进 ~/.claude/settings.json:
{
"pluginConfigs": {
"cmds@cc-mods": {
"options": {
"groups": "开工前: catchup flow raise dev; 交给它做: autopilot wf; 改代码与提交: trim scalpel ship; 线上: triage prod-tune; 后台与其他窗口: progress relay; 本机与账号: key disk weekly"
}
}
}
}
格式是 组名: 命令 命令; 组名: 命令,组之间用分号或换行隔开,命令之间用空格、逗号或顿号,命令名前的 / 可写可不写。分组按写的顺序显示,没写进任何组的命令排在最后的「其他」,写了但本机没有的命令跳过。上面那段是作者自己的分组。
pending 登记:一句问题、可选答案(推荐项排第一)、推荐哪个及理由、做决定需要的背景。/decide 打开面板列出全部待定项,再输一次关掉,面板开着时输入框上方那一行不显示。和 /cmds 一样,Claude 回答到一半时也立刻生效,面板打开就拿着键盘,焦点落在第一个选项上,Esc 关掉,放的位置也一样。每项列出问题、背景和推荐;有选项的每个选项是一个按钮,旁边的「补充」栏可以写说明;开放问题在「答复」栏写;不用再定的按「移除」。pending 工具不经权限确认直接运行,它只改面板里的列表。mod 以你的用户权限在 Claude Code 进程里运行,不在沙箱里。装之前可以对插件目录运行 claude plugin validate <目录>,它不执行代码,只列出 mod 处理哪些事件、调用哪些接口:
| mod | 处理的事件 | 调用的接口 |
|---|---|---|
| shot-view | tool.call、command.run、ui.render | $.process.run(只运行 sips、qlmanage、open)、$.fs.stat、$.clock.*、$.ui.*、$.state.* |
| task-eta | turn.start、tool.call、turn.complete、command.run、session.end、ui.render | $.model.fork、$.clock.*、$.ui.* |
| ctx-slim | prompt.attachment、ui.render(没配规则时不注册任何钩子) | $.fs.exists、$.fs.ancestors、$.env.get、$.ui.*、$.state.* |
| cmds | session.start、command.run、turn.complete、ui.render | $.command.register、$.session.cwd、$.env.get(只读 HOME、CLAUDE_CONFIG_DIR)、$.fs.list、$.fs.read(只读两个命令目录)、$.ui.*、$.state.* |
| decide | session.start、prompt.compose、tool.check(只放行自己的 pending)、tool.call、command.run、ui.close、turn.complete、ui.render | $.tool.register、$.command.register、$.prompt.submit(只在你按提交时)、$.session.id、$.clock.now、$.store.*、$.ui.*、$.state.* |
claude plugin validate shot-view # 静态检查
claude plugin test shot-view # 跑 tests/ 里的测试
claude --plugin-dir ./shot-view # 只在这一个会话里加载,存盘自动重载
brand/promo/intro/ 是介绍视频的源码(HyperFrames 0.8.96 + GSAP),分镜在 storyboard.md,录屏数据在 assets/term.js。配乐文件不在仓库里,先用 bash tools/cut_music.sh <原曲> 从原曲剪出 assets/audio.wav,再在该目录运行 npm ci、npx hyperframes render . -o renders/cc-mods-intro.mp4 --strict 重新出片。
README 和视频里的终端画面来自真实会话的录屏数据;shot-view 面板里的图片区域是后期合成的,因为录屏用的终端模拟器不支持 kitty 图形协议。视频里的计数器 mod 是官方文档的示例,拦截 rm -rf 的是官方示例 blast-radius。配乐:《栖谷来信》(Letter from an old friend),制造木屋/陈越龙,版权归原作者,不在本仓库的 MIT 许可之内。
hooks/register.tsx 173 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CmdsEntry } from '../types'
5
6const OTHER = '其他'
7const PANE = 'cmds'
8const ROWS = 16
9
10const tip = atom({ plugin: 'cmds', key: 'tip' } as const, 0)
11const list = atom({ plugin: 'cmds', key: 'list' } as const, [] as CmdsEntry[])
12
13type Group = { title: string; names: string[] }
14
15// 「组名: 命令 命令; 组名: 命令」,组之间用分号或换行隔开,命令之间用空格、逗号或顿号
16function parseGroups(text: string): Group[] {
17 return text
18 .split(/[;;\n]/)
19 .map(part => part.match(/^\s*([^::]+?)\s*[::](.*)$/))
20 .filter(m => m !== null)
21 .map(m => ({
22 title: m[1] ?? '',
23 names: (m[2] ?? '').split(/[\s,,、]+/).map(n => n.replace(/^\//, '')).filter(Boolean),
24 }))
25 .filter(g => g.title && g.names.length > 0)
26}
27
28// description 在括号外的第一个冒号或分号之前那段
29function firstClause(text: string): string {
30 let depth = 0
31 for (let i = 0; i < text.length; i++) {
32 const ch = text[i] ?? ''
33 if ('((【[「'.includes(ch)) depth++
34 else if ('))】]」'.includes(ch)) depth = Math.max(0, depth - 1)
35 else if (depth === 0 && '::;;'.includes(ch) && i > 0) return text.slice(0, i).trim()
36 }
37 return text.trim()
38}
39
40function field(front: string, key: string): string {
41 const m = front.match(new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'))
42 return (m?.[1] ?? '').trim().replace(/^(['"])(.*)\1$/, '$2')
43}
44
45async function readDir($: EngineInterface, dir: string): Promise<CmdsEntry[]> {
46 const entries = await $.fs.list(dir).catch(() => [])
47 const found: CmdsEntry[] = []
48 for (const entry of entries) {
49 if (!entry.name.endsWith('.md') || (entry.kind !== 'file' && !entry.isLink)) continue
50 const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => '')
51 const front = typeof text === 'string' ? (text.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '') : ''
52 found.push({
53 name: entry.name.slice(0, -3),
54 args: field(front, 'argument-hint'),
55 purpose: firstClause(field(front, 'description')),
56 })
57 }
58 return found
59}
60
61// 用户级 ~/.claude/commands(或 $CLAUDE_CONFIG_DIR/commands)和当前项目的 .claude/commands,同名以项目的为准
62async function load($: EngineInterface): Promise<void> {
63 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
64 const byName = new Map<string, CmdsEntry>()
65 const project = `${await $.session.cwd()}/.claude/commands`
66 for (const entry of [...(await readDir($, `${config}/commands`)), ...(await readDir($, project))]) {
67 byName.set(entry.name, entry)
68 }
69 await update($, list, () => [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)))
70}
71
72function grouped(entries: CmdsEntry[], groups: Group[]): { title: string; items: CmdsEntry[] }[] {
73 const byName = new Map(entries.map(e => [e.name, e]))
74 const placed = new Set<string>()
75 const result = groups.map(g => ({
76 title: g.title,
77 items: g.names.flatMap(n => {
78 const entry = byName.get(n)
79 if (!entry || placed.has(n)) return []
80 placed.add(n)
81 return [entry]
82 }),
83 }))
84 const rest = entries.filter(e => !placed.has(e.name))
85 result.push({ title: groups.length > 0 ? OTHER : '全部命令', items: rest })
86 return result.filter(g => g.items.length > 0)
87}
88
89export const register: Register = (on, options) => {
90 const groups = parseGroups(typeof options.groups === 'string' ? options.groups : '')
91
92 on('session.start', async ($, e, next) => {
93 // immediate:Claude 回答中途输入也立刻开关,不排到这一轮结束
94 await $.command.register({ name: 'cmds', description: '打开 / 关闭自定义短命令速查面板', immediate: true })
95 await load($)
96
97 return next(e)
98 })
99
100 // 同一个命令再输一次就关面板;面板拿着键盘:打开就能 ↑↓ 滚动,Esc 只关面板,不会中断 Claude
101 on('command.run', { command: 'cmds' }, async $ => {
102 if ((await $.ui.panes()).some(p => p.id === PANE)) {
103 await $.ui.close({ id: PANE })
104 } else {
105 await load($)
106 await $.ui.open({ id: PANE, title: '短命令', focus: true, closeOnEscape: true, rows: ROWS })
107 }
108
109 return {}
110 })
111
112 on('turn.complete', async ($, e, next) => {
113 await update($, tip, n => n + 1)
114
115 return next(e)
116 })
117
118 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
119 if (e.props.isDraft || e.props.isWorking) {
120 return next(e)
121 }
122 const items = grouped(await read($, list), groups).flatMap(g => g.items)
123 const item = items[(await read($, tip)) % items.length]
124 if (!item) {
125 return next(e)
126 }
127
128 return next({ ...e, props: { ...e.props, tail: `/${item.name} ${item.purpose} · /cmds 看全部` } })
129 })
130
131 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
132 const { Box, Text } = $.ui.resolve(e)
133 const sections = grouped(await read($, list), groups)
134 // 右上角留给 Claude Code 自己画的关闭标记 ✕
135 const hint = (
136 <Box marginRight={3}>
137 <Text dimColor>↑↓ 滚动 · Esc 关闭</Text>
138 </Box>
139 )
140
141 if (sections.length === 0) {
142 return (
143 <Box flexDirection="row" justifyContent="space-between">
144 <Text dimColor>没找到自定义命令:~/.claude/commands/ 和项目的 .claude/commands/ 下都没有 .md 文件</Text>
145 {hint}
146 </Box>
147 )
148 }
149
150 return (
151 <Box flexDirection="column">
152 {sections.map((g, n) => (
153 <Box key={g.title} flexDirection="column" marginBottom={n === sections.length - 1 ? 0 : 1}>
154 <Box flexDirection="row" justifyContent="space-between">
155 <Text bold>{g.title}</Text>
156 {n === 0 ? hint : null}
157 </Box>
158 {g.items.map(item => (
159 <Box key={item.name} flexDirection="column">
160 <Text>
161 <Text bold color="cyan">/{item.name}</Text>
162 {item.args ? <Text dimColor> {item.args}</Text> : null}
163 </Text>
164 {item.purpose ? <Text> {item.purpose}</Text> : null}
165 </Box>
166 ))}
167 </Box>
168 ))}
169 </Box>
170 )
171 })
172}
173types/index.d.ts 8 lines1export type CmdsEntry = { name: string; args: string; purpose: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 cmds: { tip: number; list: CmdsEntry[] }
6 }
7}
8