Shows a plain-language caption for each shell command, subagent and file edit, alongside any skin or mod.

<img src="assets/banner.svg" alt="clear-caption: subtitles for every command Claude runs" width="100%">
<b>Know what Claude is about to run, in plain English.</b>
<a href=".claude-plugin/plugin.json"><img alt="version" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FKashifManzer%2Fclear-caption%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&color=FFD54A&labelColor=0B0B0E"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-FFD54A?labelColor=0B0B0E"></a> <a href="https://code.claude.com/docs"><img alt="Claude Code mod" src="https://img.shields.io/badge/Claude%20Code-mod-FFD54A?labelColor=0B0B0E"></a> <a href="CONTRIBUTING.md"><img alt="PRs welcome" src="https://img.shields.io/badge/PRs-welcome-FFD54A?labelColor=0B0B0E"></a>
<a href="#install">Install</a> · <a href="#what-you-get">What you get</a> · <a href="#turn-it-up-or-down">Settings</a> · <a href="#good-to-know">Good to know</a> · <a href="CONTRIBUTING.md">Contribute</a>
You approve commands all day. Some of them look like this: find . -name "*.tmp" -exec rm {} \;
clear-caption adds one quiet line above each call that says what it does. Your tool rows stay exactly as they were.
Before
● Bash(find . -name "*.tmp" -exec rm {} \;)
● Bash(git reset --hard HEAD)
● Update(math.js)
After
● clear-caption: Delete all .tmp files
● Bash(find . -name "*.tmp" -exec rm {} \;)
● clear-caption: ⚠ Throw away all local changes
● Bash(git reset --hard HEAD)
● clear-caption: Fix clamp: Math.min should cap at hi, not lo.
● Update(math.js)
Paste this at the Claude Code prompt:
/plugin install clear-caption --marketplace KashifManzer/clear-caption
Answer y to add the marketplace, pick a scope, and you're done.
git reset --hard, force-pushes and more get a ⚠, even when the description sounds harmless.The ⚠ shows when the real command:
rm -r, rm --recursive) outside temp foldersgit reset --hard, git clean -f, git checkout -- … or git checkout ., git restore without --staged, git branch -D, git stash drop or git stash clear)find … -delete)sudo, pipes curl into a shell, or runs chmod -RDeleting only under /tmp, /private/tmp, /var/folders, $TMPDIR or a scratchpad folder is not marked. That includes a $VAR the same command sets to one of those, like S=/tmp/run; rm -rf $S.
It's a short list of common spellings, not a guarantee. Text inside quotes or heredocs is read as a command too.
/caption all every caption (default)
/caption risky only ⚠ captions
/caption off no captions
/caption show the current setting
Your choice is saved for every session. Turning edit captions back on with /caption all takes effect after /clear or in a new session.
description argument, so the mod asks Claude to add one, then strips it out before Claude Code checks the call. The edit runs as usual and the approval dialog shows a clean diff. An edit without the sentence just gets no line. Nothing is refused.ls and wc) into one row, the first caption sits above that row and the rest under it.description to Read too. Read ignores it. Grep and Glob refuse it when they're turned on, so such a call would fail once./clear or a new session starts clean.--resume) keeps the note it started with.clear-caption: ....Two hooks in hooks/register.tsx do the work. tool.describe asks Claude for a one-line description on Edit and Write. turn.step holds each call until its arguments arrive, logs the caption with $.ui.log, then passes the call on unchanged (edits lose only their description). The pure parts live in hooks/label.ts.
Want to try a local copy without installing?
claude --plugin-dir /path/to/clear-caption
Bug reports, ideas and PRs are all welcome. Start with CONTRIBUTING.md. Please follow the Code of Conduct, and report security issues privately as described in SECURITY.md.
MIT © KashifManzer
If clear-caption saved you a squint, a ⭐ helps other people find it. <sub>Banner type: Instrument Serif and Fira Code, both under the SIL Open Font License.</sub>
hooks/register.tsx 121 lines1import type { Register, TurnStepChunk, TurnStepInputChunk, TurnStepToolChunk } from 'claude-code'
2
3import { captionOf, EDIT_ASK, modeOf } from './label'
4
5const USAGE = 'Usage: /caption all|risky|off'
6const SAYS = { all: 'every caption', risky: 'risky only (⚠ commands)', off: 'off' }
7const EDITS = ['Edit', 'Write']
8
9// Says what a call does in plain words, as a dim transcript line, before it runs.
10// A line of its own, not the tool row: another mod (a skin) may own the row, and no mod owns this.
11export const register: Register = on => {
12 on('session.start', async ($, e, next) => {
13 await $.command.register({
14 name: 'caption',
15 description: 'Show tool captions: all, risky only, or off',
16 argumentHint: 'all|risky|off',
17 immediate: true,
18 })
19
20 return next(e)
21 })
22
23 // The mode lives in $.store, shared by every session and kept across restarts.
24 on('command.run', { command: 'caption' }, async ($, e) => {
25 const current = modeOf(await $.store.get('mode')) ?? 'all'
26
27 if (e.args.trim() === '') {
28 return { text: `Captions: ${SAYS[current]}. ${USAGE}` }
29 }
30
31 const mode = modeOf(e.args)
32
33 if (mode === null) {
34 return { text: `Captions stay ${SAYS[current]}. ${USAGE}` }
35 }
36
37 await $.store.set('mode', mode)
38
39 // Claude Code keeps the tool descriptions a session started with, so the ask for edit lines comes with /clear.
40 const edits = mode === 'all' && current !== 'all' ? '. Edit lines start after /clear if this session began without them' : ''
41
42 return { text: `Captions: ${SAYS[mode]}${edits}` }
43 })
44
45 // Edit and Write have no description field, so under `/caption all` their description asks Claude to add one.
46 // Claude Code keeps the descriptions a session started with, so a switch reaches edits after `/clear` or in a new
47 // session; `$.ui.invalidate('tool.describe')` re-runs this hook but was seen not to change what Claude is sent.
48 on('tool.describe', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
49 const described = await next(e)
50
51 if ((modeOf(await $.store.get('mode')) ?? 'all') !== 'all') {
52 return described
53 }
54
55 return { ...described, description: `${described.description}\n\n${EDIT_ASK}` }
56 })
57
58 // Logged while Claude's reply streams in, right before the call's row is passed on, so the line sits above its
59 // row. The row waits for its arguments, as the description is one of them; the call runs no later, since it
60 // runs only on whole arguments. An edit's description is taken out before Claude Code checks the call, in every
61 // mode and subagent, since Claude Code refuses an argument it doesn't know. Nothing here may throw: a stream hook
62 // that fails loses what it holds.
63 // ponytail: should Edit or Write ever get a real `description` argument, this would take it out too. The step's
64 // result (`toolUses`) still lists the field for hooks above this one; the engine runs and records the chunks.
65 on('turn.step', async function* ($, e, next) {
66 const mode = modeOf(await $.store.get('mode')) ?? 'all'
67 const quiet = e.agentId !== undefined || mode === 'off'
68
69 let tool: TurnStepToolChunk | null = null
70 let pieces: TurnStepInputChunk[] = []
71
72 const release = (call: TurnStepToolChunk) => {
73 let held: TurnStepChunk[] = [call, ...pieces]
74
75 try {
76 const args = JSON.parse(pieces.map(c => c.json).join(''))
77
78 if (EDITS.includes(call.name) && Object.hasOwn(Object(args), 'description')) {
79 const { description: _, ...rest } = args
80 held = [call, { kind: 'input', index: call.index, json: JSON.stringify(rest) }]
81 }
82
83 const caption = quiet ? null : captionOf({ ...args, tool: call.name }, mode)
84
85 if (caption !== null) {
86 $.ui.log(caption)
87 }
88 } catch {
89 // arguments that don't parse: the call can't run either, so it gets no caption
90 }
91
92 tool = null
93 pieces = []
94
95 return held
96 }
97
98 for await (const c of next(e)) {
99 if (tool !== null && c.kind === 'input' && c.index === tool.index) {
100 pieces.push(c)
101 continue
102 }
103
104 if (tool !== null) {
105 yield* release(tool)
106 }
107
108 if (c.kind === 'tool' && (EDITS.includes(c.name) || (!quiet && (c.name === 'Bash' || c.name === 'Agent')))) {
109 tool = c
110 continue
111 }
112
113 yield c
114 }
115
116 if (tool !== null) {
117 yield* release(tool)
118 }
119 })
120}
121hooks/label.ts 79 lines1const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
2
3// Model-written text is cut to one line and stripped of control characters (ESC, CR, BEL...)
4// before it reaches the terminal.
5const line = (s: string) => (s.split(/\r\n|\n|\r/)[0] ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()
6
7// ponytail: a short list of destructive spellings; others (git push origin +main) slip through, and text inside
8// quotes or heredocs is read as a command too. Add a pattern when a real miss or false alarm shows up.
9const RISKY = /\bgit\s+push\b[^\n;&|]*\s(-f|--force)\b|\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[a-zA-Z]*f|\bgit\s+checkout\s+(--\s|\.(\s|$))|\bgit\s+restore\b(?![^\n;&|]*--staged)|\bgit\s+branch\s+-D\b|\bgit\s+stash\s+(drop|clear)\b|\bfind\b[^\n;&|]*\s-delete\b|\bsudo\b|\bcurl\b[^|\n]*\|\s*(ba|z)?sh\b|\bchmod\s+-R\b/
10
11// Temp locations a recursive delete may clear without a ⚠.
12const TEMP = /^(\/tmp\/|\/private\/tmp\/|\/var\/folders\/|\$\{?TMPDIR\}?\/)|\/scratchpad\//
13
14// A recursive `rm` with a target outside temp locations, or none named. Redirections are not targets,
15// and `..` can climb out of temp, so it never counts as one. A `$VAR` the same command sets is read as
16// its value; one set twice keeps a value that is not temp, so it stays flagged.
17function deletesWork(command: string): boolean {
18 const vars = new Map<string, string>()
19
20 for (const [, name = '', value = ''] of command.matchAll(/(?:^|[\s;&])([A-Za-z_]\w*)=["']?([^\s;&"']+)/g)) {
21 const kept = vars.get(name)
22
23 if (kept === undefined || TEMP.test(kept)) {
24 vars.set(name, value)
25 }
26 }
27
28 for (const [, args = ''] of command.matchAll(/\brm\s+([^\n;&|]*)/g)) {
29 const words = args.split(/\s+/).filter(w => w !== '' && !/^\d*[<>]/.test(w))
30 const isRecursive = words.some(w => /^-[a-zA-Z]*[rR]/.test(w) || w === '--recursive')
31 const targets = words
32 .filter(w => !w.startsWith('-'))
33 .map(w => w.replace(/^["']|["']$/g, '').replace(/^\$\{?(\w+)\}?/, (whole, name: string) => vars.get(name) ?? whole))
34
35 if (isRecursive && (targets.length === 0 || targets.some(t => !TEMP.test(t) || t.includes('..')))) {
36 return true
37 }
38 }
39
40 return false
41}
42
43// What `/caption` shows: every caption, only the ⚠ ones, or none.
44export type Mode = 'all' | 'risky' | 'off'
45
46export function modeOf(v: unknown): Mode | null {
47 const mode = str(v).toLowerCase()
48 return mode === 'all' || mode === 'risky' || mode === 'off' ? mode : null
49}
50
51// Plain-language caption for a tool call, or null to say nothing.
52// Bash, Agent, Edit and Write carry the model's `description` (a claim, not proof: the tool row keeps the real
53// command or diff), so a destructive command is marked whatever its description says.
54// A subagent's own calls are skipped so they don't flood the main transcript.
55export function captionOf(e: { tool: string; agentId?: string; description?: unknown; command?: unknown }, mode: Mode = 'all'): string | null {
56 if (mode === 'off' || e.agentId !== undefined || !['Bash', 'Agent', 'Edit', 'Write'].includes(e.tool)) {
57 return null
58 }
59
60 const caption = line(str(e.description))
61
62 if (caption === '') {
63 return null
64 }
65
66 const command = str(e.command)
67
68 if (e.tool === 'Bash' && (RISKY.test(command) || deletesWork(command))) {
69 return `⚠ ${caption}`
70 }
71
72 return mode === 'all' ? caption : null
73}
74
75// Edit and Write have no description field, so their tool description asks Claude to add one; the field is taken
76// out before Claude Code checks the call, as it refuses arguments it doesn't know.
77export const EDIT_ASK =
78 'IMPORTANT: this tool takes one more argument than its schema lists: `description` (string, required). Write it first: one plain sentence of at most 15 words saying why this file changes and what you add or remove, as with Bash. It is shown to the user above the edit and removed before the tool runs. Example: {"description": "Fix clamp: it compared with lo where it needs hi.", "file_path": "...", ...}'
79