Explains a shell command in one plain-English line, with its risk, right where you approve it, so you know what you're saying yes to.

Know what you're approving. A plain-English line on what a shell command will do, with its risk, right where Claude asks for permission.
<img src="../../../assets/screens/command-explainer.svg" alt="command-explainer in a real Claude Code session" width="100%">
❯ clean up old temp files in the demo folder
Deleting .tmp files older than 30 days in acm-demo
⎿ 🛑 Danger: Permanently deletes every .tmp file under /tmp/acm-demo and its
subdirectories last modified more than 30 days ago, with no confirmation.
────────────────────────────────────────────────────────────────────────────────
Bash command
find /tmp/acm-demo -name '*.tmp' -mtime +30 -delete
Do you want to proceed?
❯ 1. Yes
2. Yes, and switch to auto mode
3. No
Permission prompts show you the command, and the command is often the hard part: find … -exec, git push --force-with-lease, a 300-character awk pipeline. command-explainer asks a small, fast model for one sentence about the command's concrete effect (which files, which remote, what gets deleted) and a risk level: 💡 Safe, ⚠️ Caution or 🛑 Danger.
$.tool.check) first. Commands your rules or mode already allow cost nothing.ls, cat file, git status, git log, …) are never sent to a model. Chains, pipes, redirects and substitutions always are./explain <command> explains any command on demand, including in claude -p./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install command-explainer@awesome-claude-mods
Requires Claude Code 2.1.287 or later.
Set these in /config, or under pluginConfigs in settings.json.
| Option | Default | What it does |
|---|---|---|
model | haiku | The model that writes the explanation. If it's refused, the session's model is used. |
minLength | 0 | Skip commands shorter than this many characters. |
| Event / API | Why |
|---|---|
tool.call on Bash | Starts the explanation alongside the call, then next(e) immediately |
$.tool.check | Explain only when the decision is ask, meaning a dialog is about to open |
$.model.complete | One short, low-effort completion, 80 tokens at most, with a 15 s timeout |
ui.render on ToolGroup / ToolUse | Draws the line under the call's own row, right above the dialog, until the call resolves |
$.state | Hands the explanation to the drawing, which redraws when it lands |
$.ui.notice | Also offered as the dialog's own notice line, for surfaces that draw one |
command.run | /explain |
claude plugin test mods/safety/command-explainer # 41 tests
$.ui.notice doesn't draw under the terminal's Bash permission dialog.hooks/register.tsx 122 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelCompleteResult, Register } from 'claude-code'
3
4import { formatNotice, isPending, isPlain, keepLatest, linesForGroup, Lru, parseExplanation, promptFor, SYSTEM } from './explain'
5
6// The line drawn under each Bash call's row while it waits or runs, by tool_use_id.
7const lines = atom({ plugin: 'command-explainer', key: 'lines' } as const, {})
8
9// Explanations by exact command, so a repeat costs nothing; a reload starts it over.
10const explained = new Lru<string, string>(200)
11// Set once the configured model was refused (an organization's allowlist):
12// the session's own model answers from then on.
13let isModelRefused = false
14let shownCount = 0
15
16async function askModel($: EngineInterface, model: string, command: string): Promise<ModelCompleteResult | undefined> {
17 const request = { system: SYSTEM, prompt: promptFor(command), maxTokens: 80, effort: 'low' as const, timeoutMs: 15000 }
18 if (!isModelRefused) {
19 try {
20 return await $.model.complete({ model, ...request })
21 } catch {
22 isModelRefused = true
23 }
24 }
25 try {
26 return await $.model.complete({ model: await $.session.model(), ...request })
27 } catch {
28 return undefined
29 }
30}
31
32/** One line on what `command` does, from the cache or one small model call. */
33async function explainCommand($: EngineInterface, model: string, command: string): Promise<string | undefined> {
34 const cached = explained.get(command)
35 if (cached !== undefined) return cached
36 const reply = await askModel($, model, command)
37 if (reply === undefined || !reply.isAnswered) return undefined
38 const parsed = parseExplanation(reply.text)
39 if (parsed === undefined) return undefined
40 const line = formatNotice(parsed)
41 explained.set(command, line)
42 return line
43}
44
45/** Explains the call where it is approved, if the person is about to be asked. */
46async function explainIfAsked($: EngineInterface, toolUseId: string, command: string, model: string, minLength: number): Promise<void> {
47 if (command.trim().length < minLength || isPlain(command)) return
48 const { decision } = await $.tool.check({ tool: 'Bash', input: { command } })
49 if (decision !== 'ask') return
50 const line = await explainCommand($, model, command)
51 if (line === undefined) return
52 shownCount += 1
53 $.ui.notice(toolUseId, line)
54 await update($, lines, prev => keepLatest({ ...prev, [toolUseId]: line }, 20))
55}
56
57export const register: Register = (on, options) => {
58 const model = String(options.model ?? 'haiku')
59 const minLength = Number(options.minLength ?? 0)
60
61 on('session.start', async ($, e, next) => {
62 try {
63 await $.command.register({
64 name: 'explain',
65 description: 'Explain a shell command in one plain sentence, with its risk',
66 argumentHint: '<command>',
67 })
68 } catch {
69 // Explanations at the permission prompt work without the command.
70 }
71 return next(e)
72 })
73
74 // The call goes on at once; the explanation lands when the model answers.
75 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
76 if (e.tool_use_id !== undefined) {
77 void explainIfAsked($, e.tool_use_id, e.command, model, minLength).catch(() => undefined)
78 }
79 return next(e)
80 })
81
82 // Under the call's row, right above its permission dialog, until the call
83 // resolves. A pending call is usually drawn inside a collapsed group of calls.
84 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
85 const known = await read($, lines)
86 const drawn = await next(e)
87 const shown = e.props.isExpanded ? [] : linesForGroup(e.props.calls, known)
88 if (shown.length === 0) return drawn
89 const { Box, Text } = $.ui.resolve(e)
90 return (
91 <Box flexDirection="column">
92 {drawn}
93 {shown.map(line => (
94 <Text dimColor>{` ⎿ ${line}`}</Text>
95 ))}
96 </Box>
97 )
98 })
99
100 on('ui.render', { component: 'ToolUse', props: { tool: 'Bash' } }, async ($, e, next) => {
101 const line = (await read($, lines))[e.props.tool_use_id]
102 const drawn = await next(e)
103 if (line === undefined || !isPending(e.props)) return drawn
104 const { Box, Text } = $.ui.resolve(e)
105 return (
106 <Box flexDirection="column">
107 {drawn}
108 <Text dimColor>{` ⎿ ${line}`}</Text>
109 </Box>
110 )
111 })
112
113 on('command.run', { command: 'explain' }, async ($, e) => {
114 const command = e.args.trim()
115 if (command === '') {
116 return { text: `Usage: /explain <command>. ${shownCount} explanation(s) shown at permission prompts this session, ${explained.size} cached.` }
117 }
118 const line = await explainCommand($, model, command)
119 return { text: line ?? 'No explanation: the model did not answer. Try again in a moment.' }
120 })
121}
122hooks/explain.ts 122 lines1// What command-explainer asks the model, how it reads the reply, and which
2// commands are too plain to spend a call on. Pure: no `$`.
3
4export type Risk = 'safe' | 'caution' | 'danger'
5
6export type Explanation = { risk: Risk | undefined; summary: string }
7
8export const SYSTEM =
9 'You explain shell commands to a developer who must decide, right now, whether to let an AI agent run them. ' +
10 'Reply with exactly one line: "<risk> — <what it does>". <risk> is safe, caution or danger: ' +
11 'safe = read-only or trivially undoable; caution = changes files, installs, network or git state; ' +
12 'danger = deletes data, rewrites history, touches credentials, system files or production. ' +
13 '<what it does> is one plain-English sentence of at most 22 words naming the concrete effect (which files, which remote, what gets deleted). ' +
14 'No markdown, no quotes, no advice.'
15
16export function promptFor(command: string): string {
17 const clipped = command.length > 4000 ? `${command.slice(0, 4000)}\n…(truncated)` : command
18 return `Command:\n${clipped}`
19}
20
21const LEADING = /^\s*[-*•"'`[(]*\s*(?:risk\s*[:=]\s*)?(safe|caution|danger)\b[\])"'`*]*\s*(?:[—–:-]+|\.|,)?\s*/i
22
23/** Reads "<risk> — <sentence>" leniently; undefined when the reply holds no sentence. */
24export function parseExplanation(reply: string): Explanation | undefined {
25 const line = reply
26 .split('\n')
27 .map(l => l.trim())
28 .find(l => l !== '')
29 if (line === undefined) return undefined
30 const match = line.match(LEADING)
31 const risk = match === null ? undefined : (match[1]!.toLowerCase() as Risk)
32 let summary = (match === null ? line : line.slice(match[0].length)).replace(/^["'`*]+|["'`*]+$/g, '').trim()
33 if (summary === '') return undefined
34 if (summary.length > 180) summary = `${summary.slice(0, 179)}…`
35 summary = summary[0]!.toUpperCase() + summary.slice(1)
36 return { risk, summary }
37}
38
39const GLYPH: Record<Risk, string> = { safe: '💡 Safe', caution: '⚠️ Caution', danger: '🛑 Danger' }
40
41export function formatNotice(explanation: Explanation): string {
42 return explanation.risk === undefined ? `💡 ${explanation.summary}` : `${GLYPH[explanation.risk]}: ${explanation.summary}`
43}
44
45const PLAIN = new Set([
46 'ls', 'pwd', 'echo', 'printf', 'cat', 'head', 'tail', 'wc', 'which', 'whoami', 'date', 'true', 'file', 'stat',
47 'du', 'df', 'tree', 'type', 'uname', 'env', 'printenv', 'hostname', 'id', 'basename', 'dirname', 'realpath',
48])
49const PLAIN_GIT = new Set(['status', 'diff', 'log', 'show', 'blame', 'rev-parse', 'shortlog', 'describe', 'ls-files'])
50
51/**
52 * Commands whose effect is obvious from reading them: one read-only program, no
53 * chaining, pipes, redirects or substitutions. Explaining these spends tokens
54 * and says nothing.
55 */
56export function isPlain(command: string): boolean {
57 const trimmed = command.trim()
58 if (trimmed === '') return true
59 if (/[;&|<>`\n]|\$\(/.test(trimmed)) return false
60 const words = trimmed.split(/\s+/)
61 const head = words[0]!.split('/').pop()!
62 if (PLAIN.has(head)) return !(head === 'env' && words.length > 1)
63 if (head === 'git') {
64 const sub = words.find((w, i) => i > 0 && !w.startsWith('-'))
65 if (sub === undefined) return true
66 if (PLAIN_GIT.has(sub)) return true
67 if (sub === 'branch') return !words.some(w => /^-[a-zA-Z]*[dDmMcC]/.test(w) || w === '--delete' || w === '--move')
68 if (sub === 'remote') return words.length <= 3 && (words[2] === undefined || words[2] === '-v')
69 }
70 return false
71}
72
73/** A Map that forgets its oldest entry past `limit`, and refreshes an entry on read. */
74export class Lru<K, V> {
75 private readonly entries = new Map<K, V>()
76 constructor(private readonly limit: number) {}
77
78 get(key: K): V | undefined {
79 const value = this.entries.get(key)
80 if (value === undefined) return undefined
81 this.entries.delete(key)
82 this.entries.set(key, value)
83 return value
84 }
85
86 set(key: K, value: V): void {
87 this.entries.delete(key)
88 this.entries.set(key, value)
89 while (this.entries.size > this.limit) this.entries.delete(this.entries.keys().next().value as K)
90 }
91
92 get size(): number {
93 return this.entries.size
94 }
95}
96
97/** The `limit` most recently added entries of a record (insertion order). */
98export function keepLatest<V>(record: Readonly<Record<string, V>>, limit: number): Record<string, V> {
99 const entries = Object.entries(record)
100 return Object.fromEntries(entries.slice(Math.max(0, entries.length - limit)))
101}
102
103type CallState = { isRunning: boolean; isErrored: boolean; isInterrupted: boolean; output?: unknown }
104type GroupCall = CallState & { tool_use_id?: string; tool: string }
105
106/**
107 * Not resolved yet: waiting at its permission dialog (where `isRunning` is
108 * still false) or running. A resolved call has an output, an error or an abort.
109 */
110export function isPending(call: CallState): boolean {
111 return call.isRunning || (call.output === undefined && !call.isErrored && !call.isInterrupted)
112}
113
114/** The explanations to draw under a collapsed group: its pending Bash calls that have one. */
115export function linesForGroup(calls: readonly GroupCall[], known: Readonly<Record<string, string>>): string[] {
116 return calls.flatMap(call => {
117 if (call.tool !== 'Bash' || !isPending(call) || call.tool_use_id === undefined) return []
118 const line = known[call.tool_use_id]
119 return line === undefined ? [] : [line]
120 })
121}
122types/index.d.ts 9 lines1/** The explanation drawn under each Bash call's row, by tool_use_id (the latest few). */
2export type ExplanationLines = Record<string, string>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'command-explainer': { lines: ExplanationLines }
7 }
8}
9