SLOPSHOPPER

blast-radius

Before a risky Bash command runs, says what it will touch. A plain deletion is judged by git: files outside git or tracked and clean get only a short label, a…

newguardcommandprocess
v0.5.4no licenseupdated 2026-10-08oleksandrkodua/qa-mods/blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
› fix the failing auth test and add an audit log call ● blast-radius: ⚠ Blast Radius [LOW] ⏺ 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 › /blast ⎿ blast-radius: ⚠ Blast Radius [LOW]: Видалення 0 файлів (поза git). Команда: rm -rf build. Will touch: build (matches nothing ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

qa-mods

Fourteen Claude Code mods, packaged as one plugin marketplace (qa-mods). Made for a manual QA workflow: guard rails around risky commands, evidence for what was tested, and a status band above the prompt.

Install

Run in your own Terminal (a sandboxed Claude session cannot write ~/.claude).

claude plugin marketplace add oleksandrkodua/qa-mods
claude plugin install hud@qa-mods

Replace hud with any plugin from the table.

To install everything from a local clone, or to set up a fresh machine, see INSTALL.md and install-pack.sh.

Plugins

PluginWhat it does
sandbox-guardDenies writes to protected Claude config paths and hands back a Terminal command
remote-gateAsks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out
blast-radiusShows what a risky Bash command will touch before it runs
retry-analyzerDetects repeated identical failing tool calls and tells Claude to change strategy
secret-redactorMasks tokens and keys in tool output
evidence-saverSaves screenshots from tool results to a folder you pick
replay-theater/replay: step through the session's file edits one by one
verification-guardChecks claims like "tests pass" against tool calls that actually ran
handoffOffers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt
notifyA toast and a macOS notification when a long command or turn finishes
quick-actionsCompact and Clear buttons, each asks to confirm first
hudContext fill and rate-limit windows above the prompt, a кеш 59:48 chip (m:ss) while the prompt cache is warm, five buttons, and /hud with the figures as text
next-steps-ukFork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps
plan-progressFork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods

Tests

claude plugin test hud                                              # from the repo root
cd hud && node --test tests/format.test.mjs tests/handoff-prompt.test.mjs

A bare node --test also picks up register.test.ts, which only runs under claude plugin test.

License

No license file for the original mods yet. The two forks keep their upstream MIT notices: next-steps-uk/NOTICE.md and plan-progress/NOTICE.md (plus plan-progress/LICENSE.upstream).

Source 3 files
hooks/register.tsx 173 lines
1import type { Register } from 'claude-code'
2
3import { analyze } from './analyze'
4import { CHOICES, deleteQuestion, deleteTitle, hardQuestion, isHomeTarget, levelOf, parseFacts, reasonUk, readAnswer, stateWords } from './decide'
5import type { Facts, Level } from './decide'
6
7const MAX_PATHS = 5
8const MAX_LISTED = 8
9
10/**
11 * Blast Radius: before a risky Bash command runs, say what it will touch.
12 * Static analysis plus read-only listing (globs are expanded by bash from a
13 * quoted positional, never eval'd, so nothing in the command is executed).
14 *
15 * The transcript gets one short label. The details are appended to the end of the command's
16 * own output (the part inside the tool row that the person opens with its chevron) and printed
17 * by /blast. The desktop app draws that row itself, so a render hook on the row cannot reach
18 * it; the output is the one place that works. The model reads the appended block too.
19 */
20export const register: Register = on => {
21  // /blast: the one-paragraph report of the last risky command
22  let last = ''
23
24  on('session.start', async ($, e, next) => {
25    // a name already taken must not break the mod
26    await $.command.register({ name: 'blast', description: 'Show the full Blast Radius report of the last risky command' }).catch(() => undefined)
27
28    return next(e)
29  })
30
31  on('command.run', { command: 'blast' }, () => ({ text: last || 'Blast Radius: no risky command so far in this session.' }))
32
33  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
34    // multi-line report appended to this call's output
35    let block = ''
36
37    try {
38      const a = analyze(String(e.command ?? ''))
39
40      if (a.risk !== 'none') {
41        // 1. what it will touch (read-only preview; globs are expanded by bash, never eval'd)
42        const touched: string[] = []
43
44        for (const p of a.paths.slice(0, MAX_PATHS)) {
45          const r = await $.process.run([
46            'bash',
47            '-c',
48            `shopt -s nullglob; IFS=$'\\n'; set -- $1; echo "$#"; printf "%s\\n" "$@" | head -n ${MAX_LISTED}`,
49            '_',
50            p,
51          ])
52
53          const [count, ...sample] = String(r.stdout ?? '').split('\n').filter(Boolean)
54          const n = Number(count)
55
56          touched.push(
57            !n
58              ? `${p} (matches nothing)`
59              : n === 1 && sample[0] === p
60                ? p
61                : `${p} → ${n} path(s): ${sample.join(', ')}${n > MAX_LISTED ? ', …' : ''}`,
62          )
63        }
64
65        if (a.paths.length > MAX_PATHS) touched.push(`…and ${a.paths.length - MAX_PATHS} more path argument(s)`)
66
67        if (a.git) {
68          // in the repo where the command works (`cd dir &&`, `git -C dir`), not the session's
69          let n = 0
70
71          for (const dir of new Set(a.gitDirs.length ? a.gitDirs : [''])) {
72            const where = (dir || '.').replaceAll('$HOME', String((await $.env.get('HOME').catch(() => '')) ?? '')).replace(/^~(?=\/|$)/, String((await $.env.get('HOME').catch(() => '')) ?? ''))
73            const r = await $.process.run(['git', '-C', where, 'status', '--porcelain'])
74
75            n += String(r.stdout ?? '').split('\n').filter(Boolean).length
76          }
77
78          touched.push(`git: ${n} file(s) with uncommitted changes in the whole repo (the command may touch only some of them)`)
79        }
80
81        // 2. how bad it is: a plain deletion is judged by what git knows about the files, anything else harder is always asked
82        const home = String((await $.env.get('HOME').catch(() => '')) ?? '')
83        const segment = a.segment || String(e.command ?? '')
84        let level: Level = a.risk === 'high' ? 'dialog' : 'medium'
85        let title = a.reasons.map(reasonUk).join('; ')
86        let question = hardQuestion(a.reasons, segment)
87
88        if (a.risk === 'high' && !a.hard && a.deletes.length > 0) {
89          const facts: Facts = { count: 0, tracked: 0, dirty: 0, untracked: 0, isHome: false }
90
91          for (const d of a.deletes) {
92            const targets = d.targets.map(t => t.replaceAll('$HOME', home).replace(/^~(?=\/|$)/, home))
93
94            if (targets.some(t => isHomeTarget(t, home))) facts.isHome = true
95
96            const r = await $.process.run([
97              'bash',
98              '-c',
99              `cd "$1" 2>/dev/null || cd .; shopt -s nullglob; IFS=$'\\n'; set -- $2
100if [ "$#" -eq 0 ]; then echo "COUNT 0"; exit 0; fi
101files=$(for t in "$@"; do if [ -d "$t" ]; then find "$t" -type f 2>/dev/null | head -n 2000; elif [ -e "$t" ] || [ -L "$t" ]; then printf '%s\\n' "$t"; fi; done | head -n 2000)
102echo "COUNT $(printf '%s\\n' "$files" | grep -c .)"
103tr=0; di=0; un=0
104for t in "$@"; do
105  # each target is judged by the repo it lives in (a nested repo is invisible from the outer one)
106  if [ -d "$t" ]; then d="$t"; p="."; else d=$(dirname "$t"); p=$(basename "$t"); fi
107  git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 || continue
108  tr=$((tr + $(git -C "$d" ls-files -- "$p" 2>/dev/null | wc -l)))
109  di=$((di + $(git -C "$d" status --porcelain -uno -- "$p" 2>/dev/null | wc -l)))
110  case "$(cd "$d" && pwd -P)" in /tmp|/tmp/*|/private/tmp|/private/tmp/*|/var/folders/*|/private/var/folders/*) continue;; esac
111  un=$((un + $(git -C "$d" ls-files --others --exclude-standard -- "$p" 2>/dev/null | grep -v -E '(^|/)(node_modules|\\.DS_Store)(/|$)|\\.log$' | wc -l)))
112done
113echo "TRACKED $tr"
114echo "DIRTY $di"
115echo "UNTRACKED $un"`,
116              '_',
117              d.cwd,
118              targets.join('\n'),
119            ])
120            const f = parseFacts(String(r.stdout ?? ''))
121
122            facts.count += f.count
123            facts.tracked += f.tracked
124            facts.dirty += f.dirty
125            facts.untracked += f.untracked
126          }
127
128          level = levelOf(false, facts)
129          title = `${deleteTitle(facts)} (${stateWords(facts)})`
130          question = deleteQuestion(facts, segment)
131        }
132
133        const label = `⚠ Blast Radius [${level === 'dialog' ? 'HIGH' : level === 'medium' ? 'MEDIUM' : 'LOW'}]`
134        // nothing is said when no path could be read (sudo, dd, curl | sh): "nothing found on disk" read like a verdict
135        const willTouch = touched.length ? `Will touch: ${touched.join(' | ')}` : ''
136
137        last = `${label}: ${title}. Команда: ${segment}.${willTouch ? ` ${willTouch}.` : ''}`
138        block = [`── ${label} (додано модом blast-radius, це не вивід команди) ──`, title, willTouch].filter(Boolean).join('\n')
139
140        // one short label; everything else is at the end of the tool row's output and under /blast
141        $.ui.log(label)
142
143        if (level === 'dialog') {
144          // Panes and logs are not drawn on every surface (VS Code); the ask dialog is. "Other" is added by the dialog itself.
145          // a dialog that fails to show counts as Скасувати: the command must not run unasked
146          const answer = readAnswer(await $.ui.ask(question, [...CHOICES]).catch(() => ''))
147
148          if (answer.kind === 'cancel') return { deny: 'Blast Radius: користувач скасував команду після перегляду.' }
149
150          if (answer.kind === 'custom') {
151            return { deny: `Blast Radius: команду не виконано. Замість «Виконати» чи «Скасувати» користувач дав своє рішення: «${answer.text}». Зроби саме так.` }
152          }
153        }
154      }
155    } catch {
156      // a preview must never break the command it previews
157    }
158
159    const ran = await next(e)
160
161    if (!block || ran.deny !== undefined || ran.isError === true) return ran
162
163    const result: any = ran.result
164
165    if (!result || typeof result !== 'object' || typeof result.stdout !== 'string') return ran
166
167    // a changed result is returned as the hook's own { result, context } (no ref/text), as Secret Redactor does
168    const sep = result.stdout === '' || result.stdout.endsWith('\n') ? '\n' : '\n\n'
169
170    return { result: { ...result, stdout: `${result.stdout}${sep}${block}` }, context: ran.context }
171  })
172}
173
hooks/analyze.ts 273 lines
1export type Risk = 'none' | 'medium' | 'high'
2
3export interface Analysis {
4  risk: Risk
5  reasons: string[]
6  /** path arguments worth expanding (may contain globs) */
7  paths: string[]
8  /** true when the command acts on the git working tree */
9  git: boolean
10  /** the first segment that raised the risk (what the dialog shows instead of the whole command line) */
11  segment: string
12  /** true when something other than plain file deletion is high risk (sudo, dd, git reset --hard ...): always asked */
13  hard: boolean
14  /** every rm / unlink / shred: where it runs and what it names, so the mod can look at git state and counts */
15  deletes: { cwd: string; targets: string[]; recursive: boolean }[]
16  /** where each git command works (after `cd dir &&` and `git -C dir`), so git state is read in that repo, not the session's */
17  gitDirs: string[]
18}
19
20/** Split a command line into words, honouring single and double quotes. */
21export function words(cmd: string): string[] {
22  const out: string[] = []
23  let cur = ''
24  let q: string | null = null
25  let has = false
26
27  for (const ch of cmd) {
28    if (q) {
29      if (ch === q) q = null
30      else cur += ch
31    } else if (ch === "'" || ch === '"') {
32      q = ch
33      has = true
34    } else if (/\s/.test(ch)) {
35      if (has || cur) out.push(cur)
36      cur = ''
37      has = false
38    } else {
39      cur += ch
40      has = true
41    }
42  }
43
44  if (has || cur) out.push(cur)
45
46  return out
47}
48
49/** Split on ; && || | and newlines (outside quotes, roughly). */
50export function segments(cmd: string): string[] {
51  return cmd
52    .split(/\n|;|&&|\|\||\|/)
53    .map(s => s.trim())
54    .filter(Boolean)
55}
56
57/** Drop heredoc bodies (`<<EOF ... EOF`): they are data/code for another program, not shell. */
58export function stripHeredocs(cmd: string): string {
59  const out: string[] = []
60  let end: string | null = null
61  let keep = false
62
63  for (const line of cmd.split('\n')) {
64    if (end !== null) {
65      if (line.trim() === end) end = null
66      else if (keep) out.push(line)
67
68      continue
69    }
70
71    out.push(line)
72
73    const m = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(line)
74
75    if (m) {
76      end = m[2]!
77      keep = /\b(ba|z|da|)sh\s*(-\w+\s*)*(-\s*)?<</.test(line) // a shell reads this body: it is real commands
78    }
79  }
80
81  return out.join('\n')
82}
83
84/** Empty out quoted strings so operators inside them (`"a > b"`, `=>`) are not read as shell. */
85const maskQuotes = (s: string) => s.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
86
87const RANK: Record<Risk, number> = { none: 0, medium: 1, high: 2 }
88
89function bump(a: Analysis, risk: Risk, reason: string, seg = '', isDelete = false) {
90  if (RANK[risk] > RANK[a.risk]) {
91    a.risk = risk
92    a.segment = seg
93  }
94
95  if (risk === 'high' && !isDelete) a.hard = true
96  if (!a.reasons.includes(reason)) a.reasons.push(reason)
97}
98
99const hasFlag = (args: string[], short: string, long?: string) =>
100  args.some(
101    w =>
102      (/^-[a-zA-Z]+$/.test(w) && w.slice(1).includes(short)) ||
103      (long !== undefined && w === long),
104  )
105
106const operands = (args: string[]) => args.filter(w => !w.startsWith('-'))
107
108export function analyze(raw: string): Analysis {
109  const cmd = stripHeredocs(raw)
110  const a: Analysis = { risk: 'none', reasons: [], paths: [], git: false, segment: '', hard: false, deletes: [], gitDirs: [] }
111  // `cd dir && rm x`: relative paths belong to the directory the last cd moved to
112  let cwd = ''
113  const at = (p: string) => (cwd && !/^([/~]|\$)/.test(p) ? `${cwd.replace(/\/$/, '')}/${p}` : p)
114  const push = (...p: string[]) => a.paths.push(...p.map(at))
115
116  if (/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|)sh\b/.test(cmd)) {
117    bump(a, 'high', 'pipes a download into a shell', cmd)
118  }
119
120  for (const seg of segments(cmd)) {
121    let w = words(seg.replace(/^\(+\s*/, ''))
122
123    while (w[0] && (/^\w+=/.test(w[0]) || w[0] === 'env')) w = w.slice(1)
124
125    let bin = w[0]
126    let args = w.slice(1)
127
128    if (bin === 'sudo') {
129      bump(a, 'high', 'runs with sudo', seg)
130      bin = args[0]
131      args = args.slice(1)
132    }
133
134    if (!bin) continue
135
136    if (bin === 'cd' && args[0] && !args[0].startsWith('-')) {
137      cwd = at(args[0])
138      continue
139    }
140
141    switch (bin) {
142      case 'rm':
143      case 'rmdir':
144      case 'unlink':
145      case 'shred': {
146        const recursive = hasFlag(args, 'r', '--recursive') || hasFlag(args, 'R')
147        const force = hasFlag(args, 'f', '--force')
148
149        // rm has no trash: every deletion is permanent, so only rmdir (empty dirs) stays medium
150        bump(
151          a,
152          bin === 'rmdir' ? 'medium' : 'high',
153          `deletes files permanently${recursive ? ', recursively' : ''}${force ? ', without prompts' : ''}`,
154          seg,
155          true,
156        )
157
158        push(...operands(args))
159        a.deletes.push({ cwd, targets: operands(args), recursive })
160        break
161      }
162      case 'mv':
163      case 'cp': {
164        bump(a, 'medium', `${bin} can overwrite existing files`, seg)
165        push(...operands(args).slice(-1))
166        break
167      }
168      case 'chmod':
169      case 'chown':
170      case 'chgrp': {
171        const rec = hasFlag(args, 'R', '--recursive')
172
173        bump(a, rec ? 'high' : 'medium', `${bin} changes permissions/ownership${rec ? ' recursively' : ''}`, seg)
174        push(...operands(args).slice(1))
175        break
176      }
177      case 'dd':
178      case 'mkfs':
179      case 'diskutil':
180      case 'truncate': {
181        bump(a, 'high', `${bin} can destroy data at block/file level`, seg)
182        break
183      }
184      case 'find': {
185        if (args.includes('-delete') || args.includes('-exec')) {
186          bump(a, 'high', 'find with -delete/-exec acts on every match', seg)
187          push(args[0] && !args[0].startsWith('-') ? args[0] : '.')
188        }
189
190        break
191      }
192      case 'sed':
193      case 'perl': {
194        if (hasFlag(args, 'i')) {
195          bump(a, 'medium', `${bin} -i edits files in place`, seg)
196
197          // the script is the first operand unless -e/-f give it; the BSD `-i ''` suffix is an empty word
198          const files: string[] = []
199          let hasScript = false
200
201          for (let k = 0; k < args.length; k++) {
202            const w = args[k]!
203
204            if (w === '-e' || w === '-f' || w.startsWith('--expression') || w.startsWith('--file')) {
205              hasScript = true
206              if (!w.includes('=')) k++
207            } else if (!w.startsWith('-') && w !== '') files.push(w)
208          }
209
210          push(...(hasScript ? files : files.slice(1)))
211        }
212
213        break
214      }
215      case 'git': {
216        // git's own options come before the subcommand: `git -C dir reset --hard`, `git -c k=v clean -fd`
217        let at = 0
218        let gdir = cwd
219
220        while (at < args.length && args[at]!.startsWith('-')) {
221          if (args[at] === '-C' && args[at + 1] !== undefined) {
222            const p = args[at + 1]!
223
224            gdir = /^([/~]|\$)/.test(p) || !gdir ? p : `${gdir.replace(/\/$/, '')}/${p}`
225          }
226
227          at += ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'].includes(args[at]!) ? 2 : 1
228        }
229
230        const sub = args[at]
231        const rest = args.slice(at + 1)
232
233        if (sub === 'reset' && rest.includes('--hard')) {
234          bump(a, 'high', 'git reset --hard discards uncommitted changes', seg)
235          { a.git = true; a.gitDirs.push(gdir) }
236        } else if (sub === 'clean' && (hasFlag(rest, 'f', '--force') || rest.includes('-fd'))) {
237          bump(a, 'high', 'git clean deletes untracked files', seg)
238          { a.git = true; a.gitDirs.push(gdir) }
239        } else if (
240          (sub === 'checkout' && (rest.includes('--') || rest.includes('.'))) ||
241          (sub === 'restore' && !rest.includes('--staged'))
242        ) {
243          bump(a, 'high', `git ${sub} overwrites working-tree files`, seg)
244          { a.git = true; a.gitDirs.push(gdir) }
245        // git push: the remote-gate mod asks (remote, branch, outgoing commits); one dialog per push
246        } else if (sub === 'branch' && rest.includes('-D')) {
247          bump(a, 'high', 'git branch -D deletes a branch even if unmerged', seg)
248        } else if (sub === 'stash' && (rest[0] === 'drop' || rest[0] === 'clear')) {
249          bump(a, 'medium', 'drops stashed work', seg)
250        }
251
252        break
253      }
254    }
255  }
256
257  // truncating redirect to a file: `> file` (but not >&2, >>, 2>/dev/null)
258  // (not `=>`, `->`, `>=`, and nothing inside quotes or heredoc bodies)
259  const redirect = /(^|[^>&\d=-])>(?!=)\s*([^\s>&|;"']+)/.exec(maskQuotes(cmd))
260
261  if (redirect && !redirect[2]!.startsWith('/dev/')) {
262    bump(a, 'medium', `redirect truncates ${redirect[2]}`, cmd)
263    push(redirect[2]!)
264  }
265
266  // `(cd x && rm y)`: the closing bracket of the subshell is not part of the file name (a name like `a (1)` keeps its own pair)
267  const unwrap = (x: string) => (x.split(')').length > x.split('(').length ? x.replace(/\)+$/, '') : x)
268
269  a.paths = [...new Set(a.paths.map(unwrap))]
270
271  return a
272}
273
hooks/decide.ts 108 lines
1/** What a deletion touches, read from disk and git (read-only). */
2export interface Facts {
3  count: number
4  /** how many of them git tracks */
5  tracked: number
6  /** tracked files with uncommitted changes (staged or not) */
7  dirty: number
8  /** files git does not track and does not ignore (not counting temp files): nothing can bring them back */
9  untracked: number
10  /** a target is the home folder (or something above it) */
11  isHome: boolean
12}
13
14export type Level = 'dialog' | 'medium' | 'low'
15
16/** Where the line is: a dialog only for work that cannot come back from git. */
17export const MANY = 20
18
19export function parseFacts(stdout: string): Pick<Facts, 'count' | 'tracked' | 'dirty' | 'untracked'> {
20  const num = (key: string) => Number(new RegExp(`^${key} (\\d+)`, 'm').exec(stdout)?.[1] ?? 0)
21
22  return { count: num('COUNT'), tracked: num('TRACKED'), dirty: num('DIRTY'), untracked: num('UNTRACKED') }
23}
24
25/** `~`, `$HOME`, `~/*`, `/Users/me/`, `/Users`, `/`: deleting this is deleting a life. */
26export function isHomeTarget(target: string, home: string): boolean {
27  const t = target
28    .replaceAll('$HOME', home)
29    .replace(/^~(?=\/|$)/, home)
30    .replace(/\/(\.\*|\*|\.)?$/, '')
31    .replace(/\/+$/, '')
32
33  if (t === '' || t === home.replace(/\/+$/, '')) return true
34
35  const parent = home.replace(/\/[^/]*\/?$/, '')
36
37  return t === parent
38}
39
40export function levelOf(hard: boolean, facts: Facts): Level {
41  if (hard || facts.dirty > 0 || facts.untracked > 0 || facts.isHome || facts.count > MANY) return 'dialog'
42
43  return facts.tracked > 0 ? 'medium' : 'low'
44}
45
46/** 1 файл, 2 файли, 5 файлів */
47export function plural(n: number, forms: [string, string, string]): string {
48  const m10 = n % 10
49  const m100 = n % 100
50  const form = m10 === 1 && m100 !== 11 ? forms[0] : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? forms[1] : forms[2]
51
52  return `${n} ${form}`
53}
54
55const short = (s: string, max: number) => {
56  const t = s.replace(/\s+/g, ' ').trim()
57
58  return t.length > max ? `${t.slice(0, max - 1)}…` : t
59}
60
61/** The state of the files in a few words, for the dialog and the line in the chat. */
62export function stateWords(f: Facts): string {
63  const parts: string[] = []
64
65  if (f.isHome) parts.push('це домашня папка')
66  if (f.dirty > 0) parts.push(`незакомічених змін: ${f.dirty}`)
67  if (f.untracked > 0) parts.push(`${plural(f.untracked, ['файл', 'файли', 'файлів'])} без git-копії, не відновити`)
68  if (f.count > MANY) parts.push('багато файлів')
69  if (parts.length === 0) parts.push(f.tracked > 0 ? 'усі в git, без змін' : 'поза git')
70
71  return parts.join(', ')
72}
73
74export const deleteTitle = (f: Facts) => `Видалення ${plural(f.count, ['файлу', 'файлів', 'файлів'])}`
75
76/** One short question: what, how many, in what state, and the risky command only. */
77export function deleteQuestion(f: Facts, segment: string): string {
78  return `${deleteTitle(f)} (${stateWords(f)}). Команда: ${short(segment, 120)}. Виконати?`
79}
80
81const REASONS: Record<string, string> = {
82  'runs with sudo': 'запуск із sudo',
83  'pipes a download into a shell': 'завантаження передається прямо в shell',
84  'git reset --hard discards uncommitted changes': 'git reset --hard відкидає незакомічені зміни',
85  'git clean deletes untracked files': 'git clean видаляє файли, яких немає в git',
86  'git branch -D deletes a branch even if unmerged': 'git branch -D видаляє гілку, навіть незлиту',
87  'find with -delete/-exec acts on every match': 'find з -delete/-exec зачіпає кожен збіг',
88}
89
90export const reasonUk = (r: string) =>
91  REASONS[r] ?? (/^git (checkout|restore) overwrites/.test(r) ? 'git перезаписує файли робочої копії' : /changes permissions\/ownership recursively/.test(r) ? 'рекурсивна зміна прав або власника' : /can destroy data/.test(r) ? 'може знищити дані на рівні диска або файлу' : r)
92
93export function hardQuestion(reasons: string[], segment: string): string {
94  return `Ризик: ${reasons.map(reasonUk).join('; ')}. Команда: ${short(segment, 120)}. Виконати?`
95}
96
97export const CHOICES = ['Виконати', 'Скасувати'] as const
98
99/** What the answer means: run, cancel, or the user's own decision typed under Other. */
100export function readAnswer(answer: unknown): { kind: 'run' } | { kind: 'cancel' } | { kind: 'custom'; text: string } {
101  const text = String(answer ?? '').trim()
102
103  if (text === CHOICES[0]) return { kind: 'run' }
104  if (text === '' || text === CHOICES[1]) return { kind: 'cancel' }
105
106  return { kind: 'custom', text }
107}
108