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…

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.
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.
| Plugin | What it does |
|---|---|
sandbox-guard | Denies writes to protected Claude config paths and hands back a Terminal command |
remote-gate | Asks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out |
blast-radius | Shows what a risky Bash command will touch before it runs |
retry-analyzer | Detects repeated identical failing tool calls and tells Claude to change strategy |
secret-redactor | Masks tokens and keys in tool output |
evidence-saver | Saves screenshots from tool results to a folder you pick |
replay-theater | /replay: step through the session's file edits one by one |
verification-guard | Checks claims like "tests pass" against tool calls that actually ran |
handoff | Offers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt |
notify | A toast and a macOS notification when a long command or turn finishes |
quick-actions | Compact and Clear buttons, each asks to confirm first |
hud | Context 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-uk | Fork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps |
plan-progress | Fork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods |
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.
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).
hooks/register.tsx 173 lines1import 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}
173hooks/analyze.ts 273 lines1export 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}
273hooks/decide.ts 108 lines1/** 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