SLOPSHOPPER

jr-explain

Every shell command Claude asks permission for comes with a plain-English explanation and a risk level: low, medium or high.

newguardmodel
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-explain
A shopper browsing a rack in a slop shop
README

jr-explain

When Claude asks permission to run a shell command, the question says in plain English what the command does and how risky it is, so you can answer without reading shell.

Deletes files and folders, including everything inside them. 🔴 high risk.
Runs a project script from package.json, then uploads your commits to the remote repository. 🟡 medium risk.
  • About thirty common patterns are explained from a built-in table: deleting files, force pushes, hard resets, installs, curl | bash, sudo, Docker, processes, services, remote copies, in-place edits, reads.
  • A compound command (a && b) lists each step and takes the highest risk.
  • The engine's own reason for asking (the rule that matched) stays at the end, in brackets.
  • Commands the table does not know get no line, unless you turn on the small-model option: a one-line explanation is then asked of a small model (a few tokens per new command, cached for the session).

Only commands that are put to you are explained; commands that run without asking are left alone.

Settings (/config)

OptionDefault
Explain unknown commands with a small modeloffAsk a small model when the table does not know a command
Modelclaude-haiku-5-5The model asked

Install

/plugin install jr-explain --marketplace jrlprost/claude-code-mods

Access

Reads the command of Bash calls whose permission is being asked. With the model option on, sends unknown commands to the model through the session's own client. Writes nothing, runs nothing.

Source 2 files
hooks/register.ts 37 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Explanation } from './explain'
4import { explainCommand, parseModelAnswer, reasonFor } from './explain'
5
6const cache = new Map<string, Explanation | null>()
7
8/** A small model's explanation, for commands the table does not know. One line, cached per command. */
9async function askModel($: EngineInterface, model: string, command: string): Promise<Explanation | null> {
10  if (cache.has(command)) return cache.get(command) ?? null
11  const answer = await $.model.complete({
12    model,
13    system: 'You explain shell commands to a non-developer. Answer with one short plain-English clause saying what the command does, then " | ", then its risk: low, medium or high. Nothing else.',
14    prompt: command.slice(0, 2000),
15    maxTokens: 60,
16    timeoutMs: 5000,
17  })
18  const explanation = answer.isAnswered ? parseModelAnswer(answer.text) : null
19  cache.set(command, explanation)
20  return explanation
21}
22
23export const register: Register = (on, options) => {
24  const useModel = options.useModel === true
25  const model = String(options.model ?? 'claude-haiku-5-5')
26
27  // When a Bash call is about to be put to you, the question carries a plain-English explanation.
28  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
29    const verdict = await next(e)
30    if (verdict.decision !== 'ask') return verdict
31    const command = (e.input as { command?: string }).command ?? ''
32    const explanation = explainCommand(command) ?? (useModel ? await askModel($, model, command) : null)
33    if (!explanation) return verdict
34    return { ...verdict, reason: reasonFor(explanation, verdict.reason) }
35  })
36}
37
hooks/explain.ts 65 lines
1// Plain-English explanations of shell commands, from a table first.
2
3export type Risk = 'low' | 'medium' | 'high'
4export type Explanation = { what: string; risk: Risk }
5
6const TABLE: { test: RegExp; what: string; risk: Risk }[] = [
7  { test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/, what: 'downloads a script from the internet and runs it', risk: 'high' },
8  { test: /\bsudo\b/, what: 'runs a command as administrator', risk: 'high' },
9  { test: /\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)\b/, what: 'deletes files and folders, including everything inside them', risk: 'high' },
10  { test: /\bgit\s+push\b[^;&|]*(\s-f\b|--force)/, what: 'overwrites the remote branch history with yours', risk: 'high' },
11  { test: /\bgit\s+reset\s+--hard\b/, what: 'throws away uncommitted changes', risk: 'high' },
12  { test: /\bgit\s+clean\s+-[a-zA-Z]*f/, what: 'deletes untracked files', risk: 'high' },
13  { test: /\bgit\s+(checkout|restore)\s+(--\s+)?\./, what: 'throws away uncommitted edits in the working tree', risk: 'high' },
14  { test: /\b(dd|mkfs|diskutil\s+erase)\b/, what: 'writes directly to a disk', risk: 'high' },
15  { test: /\b(drop\s+(table|database)|truncate\s+table)\b/i, what: 'deletes database tables or data', risk: 'high' },
16  { test: /\bchmod\s+-R\b|\bchown\s+-R\b/, what: 'changes permissions on a whole folder tree', risk: 'medium' },
17  { test: /\brm\b/, what: 'deletes files', risk: 'medium' },
18  { test: /\bgit\s+push\b/, what: 'uploads your commits to the remote repository', risk: 'medium' },
19  { test: /\bgit\s+(rebase|merge|cherry-pick)\b/, what: 'rewrites or combines branch history', risk: 'medium' },
20  { test: /\bgit\s+commit\b/, what: 'records the staged changes as a commit', risk: 'low' },
21  { test: /\bgit\s+(status|log|diff|show|branch|fetch)\b/, what: 'reads the git history and state', risk: 'low' },
22  { test: /\b(npm|pnpm|yarn|bun)\s+(install|i|add|ci)\b|\bpip3?\s+install\b|\buv\s+add\b|\bcargo\s+add\b|\bgem\s+install\b|\bbrew\s+install\b/, what: 'installs packages, which may run their own install scripts', risk: 'medium' },
23  { test: /\bnpx\b|\bpnpm\s+dlx\b|\buvx\b|\bbunx\b/, what: 'downloads a package and runs it', risk: 'medium' },
24  { test: /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|build|lint|dev|start|typecheck)\b/, what: 'runs a project script from package.json', risk: 'low' },
25  { test: /\bdocker\s+(system\s+prune|volume\s+rm|rm\s+-f)\b/, what: 'deletes Docker containers, volumes or caches', risk: 'high' },
26  { test: /\bdocker\s+(run|compose\s+up|build)\b/, what: 'builds or starts containers', risk: 'medium' },
27  { test: /\b(kill|pkill|killall)\b/, what: 'stops running processes', risk: 'medium' },
28  { test: /\b(launchctl|crontab|systemctl)\b/, what: 'changes background services or scheduled jobs', risk: 'medium' },
29  { test: /\b(ssh|scp|rsync)\b/, what: 'connects to or copies files to another machine', risk: 'medium' },
30  { test: /\bsed\s+-i\b|\bperl\s+-pi\b/, what: 'edits files in place', risk: 'medium' },
31  { test: /\bmv\b/, what: 'moves or renames files', risk: 'medium' },
32  { test: /\b(curl|wget|http)\b/, what: 'fetches a web address', risk: 'low' },
33  { test: /\b(cp|mkdir|touch)\b/, what: 'copies files or creates folders', risk: 'low' },
34  { test: /\b(ls|cat|head|tail|grep|rg|find|wc|pwd|echo|which|stat|du|df)\b/, what: 'reads files or folders', risk: 'low' },
35  { test: /\b(python3?|node|ruby|bash|sh|zsh)\s+\S+/, what: 'runs a script', risk: 'medium' },
36]
37
38const RANK: Record<Risk, number> = { low: 0, medium: 1, high: 2 }
39
40const segments = (command: string) => command.split(/&&|\|\||;|\n/).map(s => s.trim()).filter(Boolean)
41
42/** One explanation per part of a compound command, then the highest risk of them. */
43export const explainCommand = (command: string): Explanation | null => {
44  const found: Explanation[] = []
45  for (const part of segments(command)) {
46    const row = TABLE.find(r => r.test.test(part))
47    if (row && !found.some(f => f.what === row.what)) found.push({ what: row.what, risk: row.risk })
48  }
49  if (found.length === 0) return null
50  const risk = found.reduce<Risk>((max, f) => (RANK[f.risk] > RANK[max] ? f.risk : max), 'low')
51  const what = found.slice(0, 3).map(f => f.what)
52  const sentence = what.length === 1 ? what[0]! : `${what.slice(0, -1).join(', ')}, then ${what[what.length - 1]}`
53  return { what: sentence.charAt(0).toUpperCase() + sentence.slice(1), risk }
54}
55
56export const RISK_MARK: Record<Risk, string> = { low: '🟢 low risk', medium: '🟡 medium risk', high: '🔴 high risk' }
57
58export const reasonFor = (e: Explanation, previous?: string) => `${e.what}. ${RISK_MARK[e.risk]}.${previous ? ` (${previous})` : ''}`
59
60/** Reads the model's one-line answer: "<explanation> | <low|medium|high>". */
61export const parseModelAnswer = (text: string): Explanation | null => {
62  const m = text.trim().match(/^(.+?)\s*\|\s*(low|medium|high)\s*$/i)
63  return m ? { what: m[1]!.replace(/\.$/, ''), risk: m[2]!.toLowerCase() as Risk } : null
64}
65