SLOPSHOPPER

command-explainer

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.

newrowsguardcommandmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · command-explainer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /explain ⎿ command-explainer: Usage: /explain <command>. 0 explanation(s) shown at permission prompts this session, 0 cached. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

💡 command-explainer

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.

Features

  • Only when you're about to be asked. It runs Claude Code's own permission check ($.tool.check) first. Commands your rules or mode already allow cost nothing.
  • Never slows the call. The tool call goes ahead at once and the explanation appears when the model answers, usually within a few seconds, while the dialog is still open.
  • Skips the obvious. Plain read-only commands (ls, cat file, git status, git log, …) are never sent to a model. Chains, pipes, redirects and substitutions always are.
  • Free repeats. The last 200 explanations are cached by exact command.
  • Works under org model policies. If your organization blocks the configured model, it falls back to the session's own model.
  • /explain <command> explains any command on demand, including in claude -p.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install command-explainer@awesome-claude-mods

Requires Claude Code 2.1.287 or later.

Configuration

Set these in /config, or under pluginConfigs in settings.json.

OptionDefaultWhat it does
modelhaikuThe model that writes the explanation. If it's refused, the session's model is used.
minLength0Skip commands shorter than this many characters.

How it works

Event / APIWhy
tool.call on BashStarts the explanation alongside the call, then next(e) immediately
$.tool.checkExplain only when the decision is ask, meaning a dialog is about to open
$.model.completeOne short, low-effort completion, 80 tokens at most, with a 15 s timeout
ui.render on ToolGroup / ToolUseDraws the line under the call's own row, right above the dialog, until the call resolves
$.stateHands the explanation to the drawing, which redraws when it lands
$.ui.noticeAlso offered as the dialog's own notice line, for surfaces that draw one
command.run/explain

Test it

claude plugin test mods/safety/command-explainer   # 41 tests

Limitations

  • Each new command at a permission prompt costs one small model call. Plain commands and repeats are free.
  • The explanation comes from a model. It's a second pair of eyes, not a guarantee, so read the command too.
  • A permission dialog doesn't have a render site of its own, so the line sits on the call's row just above the dialog. In this Claude Code release, $.ui.notice doesn't draw under the terminal's Bash permission dialog.
Source 3 files
hooks/register.tsx 122 lines
1import { 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}
122
hooks/explain.ts 122 lines
1// 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}
122
types/index.d.ts 9 lines
1/** 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