SLOPSHOPPER

delete-guard

Stops Claude before it deletes or overwrites files: a side panel lists every folder and file at stake, with Cancel and Allow buttons

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delete-guard
│ ┃ delete-guard ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No delete is waiting for an answer. │ delete-guard │ │ ⏺ Read(src/auth.ts) │ Claude wants to delete nothing that exists │ │ ⎿ Read 6 lines │ right now: confirm or cancel in the │ │ ⏺ Update(src/auth.ts) │ "Delete Guard" panel │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by delete-guard: delete-guard: the user cancelled │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /delete-guard │ ⎿ delete-guard: No delete is waiting for an answer. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · delete-guard
No delete is waiting for an answer.
README

delete-guard

[!IMPORTANT] delete-guard does not stop scripts that delete files. It only recognises delete commands that Claude types directly into its shell (rm, git clean, find -delete, …). If Claude writes a script (Python, shell, Node, …) that deletes files, or runs an existing one, the panel does not open and the files are deleted without asking. Treat the mod as a safety net for everyday commands, not as protection against data loss.

A Claude Code mod that stops Claude before it deletes files, or overwrites or empties files that hold something. A side panel lists every folder and file the command would remove or change, with Cancel and Allow buttons underneath. Nothing happens until you press one of them.

Delete Guard
Claude wants to delete 1 folder (2 files inside), 1 file
in ~/projects/analysis
$ rm -rf junk keep.txt nothere.log

Folders (1)
  ▸ junk/
     2 files, 1 folder, 4 KB
     a.txt
     sub/
     sub/b.txt
Files (1)
  • keep.txt  5 B
Not there (nothing to delete) (1)
  nothere.log  does not exist

[ Cancel ] [ Allow ]
  • Cancel: the command does not run. Claude is told you cancelled it and not to try again another way.
  • Allow: the command runs.
  • Closing the panel with its ✕, or interrupting Claude with Esc, counts as Cancel.

Install

In a Claude Code terminal session:

/plugin install delete-guard --marketplace vienna-bio-center/claude-vbc-mods

Answer y to add the marketplace, then pick the user scope. The mod is active right away and in every session you start afterwards — including the desktop app's Code tab. (The install command itself only works in the terminal.)

Usage

Nothing to start. Whenever Claude runs a shell command that deletes or overwrites something, the panel opens on its own and the command waits for your answer, however long that takes.

In the terminal: Tab moves between the buttons, Enter presses one, n is Cancel. A mouse click works too.

If the panel is closed while a delete waits, /delete-guard opens it again.

What it catches

Deleting — always asked about:

CommandWhat the panel lists
rm, rmdir, unlink, shred, trash, gio trash, rimrafthe named paths, with wildcards (*.log), {a,b}, {01..10} and ~ expanded; for a folder its size, file count and the first few entries
truncate (not when it only grows a file: -s +N)the named files, each with what happens to it (→ emptied, the file stays)
git rm (not --cached)the named paths
git cleanexactly what git clean -n (dry run) reports
find … -delete, find … -exec rm …the matches of the same find, with the delete swapped for -print
xargs rm, rsync --delete/--del, gio trash --empty, trash-empty, dd of=/dev/…, git stash drop/clear, git worktree remove --forcea note that the targets can't be known beforehand

Overwriting and throwing away changes — asked about only when something is at stake:

CommandAsked when …
> file, `>\file, &> file (not >>`)the file exists and is not empty
cp, mv, install (not with -n, -i, -b), ln -fthe file they write to exists
tee (not -a), dd of=filethe file exists (for tee: and is not empty)
git reset --hard, git restore, git checkout <file>, git checkout -- …, git checkout -f, git switch --discard-changesthere are uncommitted changes in the files it touches; git reset --hard <commit> always

Files in /tmp, $TMPDIR and /dev, and targets named by a variable (> $LOG), are not asked about.

It also looks inside cmd1 && cmd2, ;, pipes, sudo …, env …, command …, bash -c "…", npx …/npm exec …, $( … ), find -exec … and xargs …, and follows a cd (or env -C) earlier in the same command. Paths that depend on a variable (rm $OUT/x) are listed as known only when it runs.

Good to know

  • Only Claude's shell (Bash tool) is watched. Deletes through other tools (for example an MCP connector's own delete) are not caught.
  • Best effort, not a sandbox. The mod reads the command the way bash would and errs on the side of asking. Deletes inside scripts are not recognised (see the note at the top), nor are deliberately disguised commands.
  • Not a backup. After you press Allow, the files are gone as usual.
  • Narrow terminal: Claude Code shows a panel it opens on its own only from about 144 columns (110 once you have opened it before). Below that, the mod asks in Claude Code's normal question dialog instead, with a one-line summary.
  • Claude Code's own permission question: if the command does nothing but delete, your Allow is enough. Claude Code still asks its usual question afterwards when the command also does something else (rm -r build && npm install), overwrites rather than deletes (cp, >, git restore), or might run something other than the plain command: a variable set in front (PATH=… rm), a program from a folder (./rm), a package runner (npx rimraf), git options such as git -c …, or a login shell (bash -lc). Deny rules in your settings always win.
  • Previews run nothing of the command's own: the dry runs (find … -print, git clean -n, git status) leave out the command's -c … and other global git flags, switch off core.fsmonitor, and run only in this session's own repository. For another repository the panel says the targets can't be listed beforehand.
  • claude -p and other runs where nobody can answer: deletes are refused.
  • Waiting uses a sleeping sleep process (PowerShell Start-Sleep on Windows) that ends as soon as you answer. This is how a mod can wait longer than Claude Code's 10-second limit for mods.
  • Tested on Linux and macOS in the terminal. Windows is not tested.

Development

claude plugin validate <path-to-this-folder>
claude plugin test <path-to-this-folder>

To run a working copy instead of the installed version, start Claude Code with claude --plugin-dir <path-to-this-folder>.

License

MIT © 2026 Vienna BioCenter

Source 3 files
hooks/register.tsx 541 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry, Request } from '../types'
5import {
6  bytes,
7  expandBraces,
8  findDeletes,
9  hasGlob,
10  joinPath,
11  segmentRegex,
12  showPath,
13  tilde,
14  unescape,
15} from './parse'
16import type { Found } from './parse'
17
18type $ = EngineInterface
19type Answer = 'allow' | 'cancel'
20
21const PANE = 'delete-guard'
22const TITLE = 'Delete Guard'
23const queue = atom({ plugin: 'delete-guard', key: 'queue' } as const, [])
24
25/** Entries listed per command, folder entries counted per folder, paths shown inside a folder. */
26const MAX_ENTRIES = 300
27const WALK_LIMIT = 5000
28const SAMPLE = 5
29
30// The hooks waiting for an answer, by request id. A reload starts it empty and
31// `session.start` clears the queue to match.
32const waiting = new Map<string, (answer: Answer) => void>()
33// Calls the person allowed that delete and nothing else: the engine's own
34// "allow this command?" question is answered for them (see `tool.check`).
35const approved = new Set<string>()
36
37const blank = (path: string): Entry => ({
38  path,
39  kind: 'missing',
40  size: 0,
41  files: 0,
42  dirs: 0,
43  isPartial: false,
44  sample: [],
45  note: '',
46})
47
48/** Counts what lies under a folder, breadth first, so the sample shows its top level. */
49const walk = async ($: $, root: string) => {
50  let files = 0
51  let dirs = 0
52  let size = 0
53  let seen = 0
54  const sample: string[] = []
55  const todo = ['']
56  while (todo.length > 0) {
57    const rel = todo.shift()!
58    const list = await $.fs.list(rel === '' ? root : `${root}/${rel}`).catch(() => [])
59    for (const entry of [...list].sort((a, b) => a.name.localeCompare(b.name))) {
60      const path = rel === '' ? entry.name : `${rel}/${entry.name}`
61      if (++seen > WALK_LIMIT) return { files, dirs, size, sample, isPartial: true }
62      if (entry.kind === 'dir') {
63        dirs++
64        todo.push(path)
65      } else {
66        files++
67        size += entry.size
68      }
69      if (sample.length < SAMPLE) sample.push(entry.kind === 'dir' ? `${path}/` : path)
70    }
71  }
72  return { files, dirs, size, sample, isPartial: false }
73}
74
75const describe = async ($: $, abs: string, cwd: string, home: string, isDeep: boolean): Promise<Entry> => {
76  const entry = blank(showPath(abs, cwd, home))
77  const stat = await $.fs.stat(abs).catch(() => undefined)
78  if (stat === undefined) return { ...entry, note: 'does not exist' }
79  if (stat.isLink) return { ...entry, kind: 'link', note: 'a link: only the link goes, not what it points to' }
80  if (stat.kind === 'dir') return { ...entry, kind: 'dir', ...(isDeep ? await walk($, abs) : {}) }
81  return { ...entry, kind: 'file', size: stat.size }
82}
83
84/** The paths a shell word names: `~`, braces and globs expanded as bash would; `null` for none, `too-many` past the limit. */
85const expand = async ($: $, pattern: string, base: string, home: string): Promise<string[] | null | 'too-many'> => {
86  const out: string[] = []
87  const names = expandBraces(pattern)
88  if (names === null) return 'too-many'
89  for (let p of names) {
90    if (p === '~' || p.startsWith('~/')) p = home.replace(/[\\*?[\]{}~]/g, '\\$&') + p.slice(1)
91    if (!hasGlob(p)) {
92      out.push(joinPath(base, unescape(p)))
93      continue
94    }
95    const segments = p.split('/').filter(s => s !== '')
96    let found = [p.startsWith('/') ? '/' : base]
97    for (const segment of segments) {
98      if (!hasGlob(segment)) {
99        found = found.map(d => joinPath(d, unescape(segment)))
100        continue
101      }
102      const re = segmentRegex(segment)
103      const next: string[] = []
104      for (const dir of found) {
105        const list = await $.fs.list(dir).catch(() => [])
106        for (const entry of list) if (re.test(entry.name)) next.push(joinPath(dir, entry.name))
107      }
108      found = next.sort()
109    }
110    const exists = await Promise.all(found.map(f => $.fs.exists(f).catch(() => false)))
111    out.push(...found.filter((_, i) => exists[i]))
112  }
113  return out.length > 0 ? out : null
114}
115
116/** Folders where overwriting a file is never asked about: scratch space and devices. */
117const scratch = async ($: $) => {
118  const tmp = ((await $.env.get('TMPDIR')) ?? '').replace(/\/+$/, '')
119  return ['/tmp', '/private/tmp', '/dev', ...(tmp === '' ? [] : [tmp, `/private${tmp}`])]
120}
121
122/** Parts that are asked about even when nothing they name exists right now. */
123export const isAlwaysAsked = (f: Found) => (f.kind === 'overwrite' ? false : f.kind === 'git-discard' ? f.note !== undefined : true)
124
125const baseName = (s: string) => s.slice(s.lastIndexOf('/') + 1)
126
127/** Lists everything the found parts of a command would delete, cut, overwrite or throw away. */
128export const resolve = async ($: $, found: Found[], cwd: string, home: string) => {
129  const entries: Entry[] = []
130  const notes: string[] = []
131  const seen = new Set<string>()
132  const skip = await scratch($)
133  const isScratch = (abs: string) => skip.some(d => abs === d || abs.startsWith(`${d}/`))
134  let skipped = 0
135  const add = async (abs: string, isDeep: boolean, extra: Pick<Entry, 'action' | 'change'> = {}) => {
136    if (seen.has(abs)) return
137    seen.add(abs)
138    if (entries.length >= MAX_ENTRIES) skipped++
139    else entries.push({ ...(await describe($, abs, cwd, home, isDeep)), ...extra })
140  }
141  /** The folder a part runs in; `null` with the reason when that can't be known beforehand. */
142  const dirOf = async (f: { cwd: string[]; isCwdKnown: boolean }): Promise<{ dir: string | null; why: string }> => {
143    if (!f.isCwdKnown) return { dir: null, why: 'folder unknown: the command changes folder first' }
144    const dir = f.cwd.reduce((d, seg) => joinPath(d, seg === '~' || seg.startsWith('~/') ? home + seg.slice(1) : seg), cwd)
145    if (f.cwd.length > 0 && !(await $.fs.exists(dir).catch(() => false)))
146      return { dir: null, why: `folder unknown: ${tilde(dir, home)} does not exist now` }
147    return { dir, why: '' }
148  }
149  const isRelative = (w: { text: string; pattern: string }) => !w.text.startsWith('/') && !w.pattern.startsWith('~')
150  // a git dry run only in the session's own repository: another one's config (a planted filter) would run before anyone answered
151  const tops = new Map<string, Promise<string | null>>()
152  const topOf = (dir: string, argv: string[]) => {
153    if (!tops.has(dir))
154      tops.set(
155        dir,
156        $.process.run(argv, { cwd: dir, timeoutMs: 15_000 }).then(
157          r => (r.exitCode === 0 ? r.stdout.trim() : null),
158          () => null,
159        ),
160      )
161    return tops.get(dir)!
162  }
163  const isOwnRepo = async (dir: string, argv: string[]) => {
164    const [top, own] = await Promise.all([topOf(dir, argv), topOf(cwd, argv)])
165    return top !== null && top === own
166  }
167
168  for (const f of found) {
169    if (f.kind === 'opaque') {
170      notes.push(`${f.text}: ${f.why}, so it can't be listed beforehand`)
171      continue
172    }
173    const { dir, why } = await dirOf(f)
174    if (f.kind === 'paths') {
175      const extra: Pick<Entry, 'action' | 'change'> = f.change === undefined ? {} : { action: 'truncate', change: f.change }
176      for (const word of f.words) {
177        if (word.isDynamic || (dir === null && isRelative(word))) {
178          entries.push({ ...blank(word.text), ...extra, kind: 'unknown', note: word.isDynamic ? 'decided only when the command runs' : why })
179          continue
180        }
181        const paths = await expand($, word.pattern, dir ?? cwd, home)
182        if (paths === 'too-many') entries.push({ ...blank(word.text), ...extra, kind: 'unknown', note: 'too many names to list' })
183        else if (paths === null) entries.push({ ...blank(word.text), ...extra, note: 'matches nothing' })
184        else for (const p of paths) await add(p, f.change === undefined, extra)
185      }
186      continue
187    }
188    if (f.kind === 'overwrite') {
189      // only what exists now is at stake; a name known only when it runs is not asked about
190      if (f.dest.isDynamic || (dir === null && isRelative(f.dest))) continue
191      const dests = await expand($, f.dest.pattern, dir ?? cwd, home)
192      const change = { action: 'overwrite' as const, change: `overwritten by ${f.tool === '>' ? 'a > redirect' : f.tool}` }
193      for (const dest of Array.isArray(dests) ? dests : []) {
194        if (isScratch(dest)) continue
195        const stat = await $.fs.stat(dest).catch(() => undefined)
196        if (stat === undefined) continue
197        const isInto = f.intoDir ?? stat.kind === 'dir'
198        if (!isInto) {
199          if (stat.kind === 'file' && !(f.isOnlyNonEmpty && stat.size === 0)) await add(dest, false, change)
200          continue
201        }
202        if (stat.kind !== 'dir') continue
203        for (const source of f.sources) {
204          if (source.isDynamic || (dir === null && isRelative(source))) continue
205          const names = await expand($, source.pattern, dir ?? cwd, home)
206          for (const name of Array.isArray(names) ? names : []) {
207            // `cp -r src/ dest` copies what is inside src on macOS, src itself on Linux: check both
208            const inside = /\/\.?$/.test(source.text) ? (await $.fs.list(name).catch(() => [])).map(e => joinPath(dest, e.name)) : []
209            for (const target of [joinPath(dest, baseName(name)), ...inside]) {
210              if (isScratch(target) || !(await $.fs.exists(target).catch(() => false))) continue
211              await add(target, false, change)
212            }
213          }
214        }
215      }
216      continue
217    }
218    if (f.kind === 'git-discard') {
219      if (f.note !== undefined) notes.push(`${f.text}: ${f.note}`)
220      if (f.argv === null || dir === null) {
221        notes.push(`${f.text}: its changed files can't be listed beforehand`)
222        continue
223      }
224      if (!(await isOwnRepo(dir, f.rootArgv))) {
225        notes.push(`${f.text}: works outside this session's repository, so its changed files are not listed beforehand`)
226        continue
227      }
228      const top = (await topOf(dir, f.rootArgv))!
229      const ran = await $.process.run(f.argv, { cwd: dir, timeoutMs: 15_000 }).catch(() => undefined)
230      if (ran === undefined || ran.exitCode !== 0) {
231        notes.push(`${f.text}: listing its changed files beforehand failed`)
232        continue
233      }
234      const fields = ran.stdout.split('\0')
235      for (let k = 0; k < fields.length; k++) {
236        const field = fields[k]!
237        if (field.length < 4) continue
238        const [x, y] = [field[0]!, field[1]!]
239        if (x === 'R' || x === 'C') k++
240        // a deleted file comes back: nothing is lost there
241        if (f.scope === 'worktree' ? y === ' ' || y === 'D' : /^[ D]{2}$/.test(x + y)) continue
242        await add(joinPath(top, field.slice(3)), false, { action: 'discard', change: 'uncommitted changes discarded' })
243      }
244      continue
245    }
246    if (f.argv === null || dir === null) {
247      notes.push(`${f.text}: can't be listed beforehand`)
248      continue
249    }
250    if (f.kind === 'git-clean' && !(await isOwnRepo(dir, f.rootArgv))) {
251      notes.push(`${f.text}: works outside this session's repository, so it is not listed beforehand`)
252      continue
253    }
254    const ran = await $.process.run(f.argv, { cwd: dir, timeoutMs: 15_000 }).catch(() => undefined)
255    if (ran === undefined || ran.exitCode !== 0) {
256      notes.push(`${f.text}: listing its targets beforehand failed`)
257      continue
258    }
259    const lines = ran.stdout.split('\n').filter(l => l !== '')
260    if (f.kind === 'git-clean') {
261      for (const line of lines) {
262        const path = line.replace(/^Would remove /, '')
263        if (path !== line) await add(joinPath(dir, path), path.endsWith('/'))
264      }
265    } else for (const line of lines) await add(joinPath(dir, line), false)
266    if (lines.length === 0) notes.push(`${f.text}: matches nothing right now`)
267  }
268  if (skipped > 0) notes.push(`and ${skipped} more not listed here`)
269  return { entries, notes }
270}
271
272/** `1 file`, `3 files`, or `5000+ files` when counting stopped early. */
273const plural = (n: number, word: string, isMore = false) =>
274  `${n}${isMore ? '+' : ''} ${word}${n === 1 && !isMore ? '' : 's'}`
275
276/** One line for the dialog and the toast: `2 folders (130 files) and 1 file`. */
277export const summary = (entries: Entry[], notes: string[] = []) => {
278  const dirs = entries.filter(e => e.kind === 'dir')
279  const files = entries.filter(e => e.kind === 'file' || e.kind === 'link')
280  const unknown = entries.filter(e => e.kind === 'unknown')
281  const inside = dirs.reduce((n, d) => n + d.files, 0)
282  const parts = [
283    dirs.length > 0 &&
284      `${plural(dirs.length, 'folder')}${inside > 0 ? ` (${plural(inside, 'file', dirs.some(d => d.isPartial))} inside)` : ''}`,
285    files.length > 0 && plural(files.length, 'file'),
286    unknown.length > 0 && `${plural(unknown.length, 'path')} known only when it runs`,
287  ].filter((p): p is string => p !== false)
288  if (parts.length > 0) return parts.join(', ')
289  return notes.length > 0 ? "files that can't be listed beforehand" : 'nothing that exists right now'
290}
291
292const VERBS = { delete: 'delete', truncate: 'truncate', overwrite: 'overwrite', discard: 'discard changes to' } as const
293
294/** What Claude wants to do, as the dialog says it: `truncate 1 file`, `delete 2 folders`. */
295export const headline = (entries: Entry[], notes: string[] = []) => {
296  const actions = new Set(entries.map(e => e.action ?? 'delete'))
297  const verb = actions.size > 1 ? 'delete or change' : VERBS[[...actions][0] ?? 'delete']
298  return `${verb} ${summary(entries, notes)}`
299}
300
301type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
302
303/** The panel's list, folders first; each folder with its size and a few paths inside. */
304export const lines = (req: Request): Line[] => {
305  const out: Line[] = []
306  const group = (title: string, kinds: Entry['kind'][], each: (e: Entry) => Line[]) => {
307    const list = req.entries.filter(e => kinds.includes(e.kind))
308    if (list.length === 0) return
309    out.push({ text: `${title} (${list.length})`, isBold: true })
310    for (const e of list) out.push(...each(e))
311  }
312  group('Folders', ['dir'], d => {
313    const counts = `${plural(d.files, 'file', d.isPartial)}, ${plural(d.dirs, 'folder', d.isPartial)}, ${bytes(d.size)}`
314    const inside = d.sample.map(s => ({ text: `     ${s}`, isDim: true }))
315    const more = d.files + d.dirs - d.sample.length
316    return [
317      { text: `  ▸ ${d.path}/`, color: 'error' },
318      { text: `     ${d.files + d.dirs === 0 && !d.isPartial ? 'empty' : counts}`, isDim: true },
319      ...inside,
320      ...(more > 0 && d.sample.length > 0 ? [{ text: `     … ${more}${d.isPartial ? '+' : ''} more`, isDim: true }] : []),
321    ]
322  })
323  group('Files', ['file', 'link'], f => [
324    {
325      text: `  • ${f.path}  ${f.kind === 'link' ? '(link)' : bytes(f.size)}${f.change === undefined ? '' : `  → ${f.change}`}`,
326      color: 'error',
327    },
328  ])
329  group('Known only when it runs', ['unknown'], u => [{ text: `  ? ${u.path}  ${u.note}`, color: 'warning' }])
330  group('Not there (nothing to delete)', ['missing'], m => [{ text: `  ${m.path}  ${m.note}`, isDim: true }])
331  if (req.notes.length > 0) {
332    out.push({ text: 'Also', isBold: true })
333    for (const n of req.notes) out.push({ text: `  ? ${n}`, color: 'warning' })
334  }
335  return out
336}
337
338/** Keeps a list to `room` rows, its last row saying how many it left out. */
339export const fit = (all: Line[], room: number): Line[] =>
340  all.length <= room ? all : [...all.slice(0, Math.max(1, room - 1)), { text: `… ${all.length - Math.max(1, room - 1)} more lines`, isDim: true }]
341
342/** Answers the request `id`: takes it off the queue and wakes its hook. */
343const decide = async ($: $, id: string, answer: Answer) => {
344  await update($, queue, list => list.filter(r => r.id !== id))
345  waiting.get(id)?.(answer)
346  waiting.delete(id)
347}
348
349/**
350 * Waits for the answer. A hook has 10 seconds of its own time, but time spent
351 * inside a `$` call does not count, so a sleeping child process stands in as
352 * that call until the person answers. `unheld` when no child can be started.
353 */
354const hold = async ($: $, decided: Promise<Answer>, signal: AbortSignal): Promise<Answer | 'unheld'> => {
355  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
356  const argv = isWindows ? ['powershell', '-NoProfile', '-Command', 'Start-Sleep -Seconds 86400'] : ['sleep', '86400']
357  const stopped = new Promise<Answer>(done => {
358    if (signal.aborted) done('cancel')
359    else signal.addEventListener('abort', () => done('cancel'), { once: true })
360  })
361  const wanted = Promise.race([decided, stopped])
362  for (;;) {
363    const startedAt = Date.now()
364    const child = $.process.spawn({ argv })
365    const ended = child.next().then(
366      () => 'ended' as const,
367      () => 'failed' as const,
368    )
369    const first = await Promise.race([wanted, ended])
370    void child.return(undefined as never).catch(() => undefined)
371    if (first === 'failed' || (first === 'ended' && Date.now() - startedAt < 1000)) return 'unheld'
372    if (first !== 'ended') return first
373  }
374}
375
376/** The question in the engine's dialog: what, the command itself, and what can't be listed. */
377export const question = (req: Request) => {
378  const command = req.command.length > 200 ? `${req.command.slice(0, 200)}…` : req.command
379  const also = req.notes
380    .slice(0, 3)
381    .map(n => ` Note: ${n}.`)
382    .join('')
383  return `Claude wants to ${headline(req.entries, req.notes)} with \`${command}\`.${also} Allow it?`
384}
385
386/** The engine's own question, for when the panel can't be shown or held open. */
387const askInstead = async ($: $, req: Request, decided: Promise<Answer>): Promise<Answer> => {
388  const asked = $.ui
389    .ask(question(req), {
390      options: ['Cancel', 'Allow'],
391      header: 'Delete Guard',
392    })
393    .then(
394      (a): Answer => (a === 'Allow' ? 'allow' : 'cancel'),
395      (): Answer => 'cancel',
396    )
397  return Promise.race([decided, asked])
398}
399
400/** Status line and panel follow the queue. Only for show: a failure here never decides a delete. */
401const refresh = async ($: $) => {
402  try {
403    const list = await read($, queue)
404    if (list.length === 0) {
405      $.ui.status(undefined)
406      await $.ui.close({ id: PANE })
407    } else $.ui.status(`${plural(list.length, 'delete')} waiting for your answer`)
408  } catch {
409    // the answer stands either way
410  }
411}
412
413export const register: Register = on => {
414  on('session.start', async ($, e, next) => {
415    await $.state.set({ plugin: 'delete-guard', key: 'queue' } as const, [])
416    await $.command.register({
417      name: 'delete-guard',
418      description: 'Show the panel of deletes waiting for your answer',
419    })
420    return next(e)
421  })
422
423  on('command.run', { command: 'delete-guard' }, async $ => {
424    const list = await read($, queue)
425    if (list.length === 0) return { text: 'No delete is waiting for an answer.' }
426    await $.ui.open({ id: PANE, title: TITLE, focus: true })
427    return { text: 'Opened the delete panel.' }
428  })
429
430  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
431    const { found, isOnlyDeletes } = findDeletes(e.command)
432    if (found.length === 0) return next(e)
433
434    const cwd = await $.session.cwd()
435    const home = (await $.env.get('HOME')) ?? ''
436    const { entries, notes } = await resolve($, found, cwd, home)
437    // only overwrites of files that don't exist (or are empty): nothing to lose
438    if (!found.some(isAlwaysAsked) && entries.length === 0 && notes.length === 0) return next(e)
439
440    // Nobody to ask (a `claude -p` run): refuse rather than delete unseen.
441    if ((await $.session.surfaces()).length === 0)
442      return {
443        deny: 'delete-guard: deleting or overwriting files needs a person to confirm it, and nobody can be asked in this session.',
444      }
445    const req: Request = { id: e.tool_use_id, command: e.command, cwd, entries, notes }
446
447    const decided = new Promise<Answer>(done => waiting.set(req.id, done))
448    await update($, queue, list => [...list.filter(r => r.id !== req.id), req])
449    await refresh($)
450    const opened = await $.ui
451      .open({ id: PANE, title: TITLE, focus: true })
452      .catch(() => ({ isPlaced: false as const, reason: 'the panel could not be opened' }))
453    if (opened.isPlaced) $.ui.toast(`Claude wants to ${headline(entries, notes)}: confirm or cancel in the "${TITLE}" panel`)
454
455    let answer: Answer
456    try {
457      const held = opened.isPlaced ? await hold($, decided, next.signal) : 'unheld'
458      answer = held === 'unheld' ? await askInstead($, req, decided) : held
459    } finally {
460      waiting.delete(req.id)
461      await update($, queue, list => list.filter(r => r.id !== req.id))
462      await refresh($)
463    }
464
465    if (answer === 'cancel')
466      return {
467        deny:
468          'delete-guard: the user cancelled this command, nothing was deleted or overwritten. Do not retry it or change these files another way unless the user asks for it.',
469      }
470    if (isOnlyDeletes) approved.add(req.id)
471    try {
472      return await next(e)
473    } finally {
474      approved.delete(req.id)
475    }
476  }).catch(($, e, next) =>
477    next.called ? next(e) : { deny: 'delete-guard: this command deletes files and could not be checked, so it was blocked.' },
478  )
479
480  // Allowed in the panel: the engine's own "run this command?" would ask the
481  // same again. Only for a command that does nothing but delete; a settings
482  // rule that denies it still wins.
483  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
484    const verdict = await next(e)
485    return e.tool_use_id !== undefined && approved.has(e.tool_use_id) && verdict.decision === 'ask'
486      ? { ...verdict, decision: 'allow' as const, reason: 'confirmed in the delete-guard panel' }
487      : verdict
488  })
489
490  // Closing the panel by hand cancels everything it was asking about.
491  on('ui.close', { id: PANE }, async ($, e, next) => {
492    if (e.origin.kind === 'person') for (const r of await read($, queue)) await decide($, r.id, 'cancel')
493    return next(e)
494  })
495
496  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
497    const { Box, Text, Button } = $.ui.resolve(e)
498    const list = await read($, queue)
499    const req = list[0]
500    if (req === undefined) return <Text dimColor>No delete is waiting for an answer.</Text>
501
502    const home = (await $.env.get('HOME')) ?? ''
503    const command = req.command.length > 300 ? `${req.command.slice(0, 300)}…` : req.command
504    const room = Math.max(6, e.props.scroll.bodyRows - 10)
505
506    return (
507      <Box flexDirection="column">
508        <Box flexDirection="column">
509          <Text bold color="error">
510            {TITLE}
511          </Text>
512          <Text bold color="error">
513            Claude wants to {headline(req.entries, req.notes)}
514          </Text>
515        </Box>
516        {list.length > 1 && <Text color="warning">1 of {list.length} waiting</Text>}
517        <Text dimColor wrap="truncate-middle">
518          in {tilde(req.cwd, home)}
519        </Text>
520        <Text dimColor>$ {command}</Text>
521        <Text> </Text>
522        {fit(lines(req), room).map(line => (
523          <Text color={line.color} dimColor={line.isDim} bold={line.isBold} wrap="truncate-middle">
524            {line.text}
525          </Text>
526        ))}
527        <Text> </Text>
528        <Box flexDirection="row">
529          <Button key="cancel" hotkey="n" autoFocus onPress={() => decide($, req.id, 'cancel')}>
530            Cancel
531          </Button>
532          <Text> </Text>
533          <Button key="allow" variant="primary" onPress={() => decide($, req.id, 'allow')}>
534            Allow
535          </Button>
536        </Box>
537      </Box>
538    )
539  })
540}
541
hooks/parse.ts 1041 lines
1// Reads a shell command the way bash would split it and finds the parts that delete files.
2// Pure: no `$`, so it is tested on its own. What it can't read safely it reports as `opaque`.
3
4/** One shell word: `text` as bash would pass it, `pattern` with quoted glob/brace/tilde characters escaped. */
5export type Word = { text: string; pattern: string; isDynamic: boolean }
6
7export type Found =
8  | { kind: 'paths'; tool: string; cwd: string[]; isCwdKnown: boolean; words: Word[]; change?: string }
9  | { kind: 'find'; cwd: string[]; isCwdKnown: boolean; argv: string[] | null; text: string }
10  | { kind: 'git-clean'; cwd: string[]; isCwdKnown: boolean; argv: string[] | null; rootArgv: string[]; text: string }
11  /**
12   * A file written from the start (`>`, `cp`, `tee`, ...): asked about only when
13   * it exists (and, with `isOnlyNonEmpty`, holds something). `intoDir`: `dest`
14   * is a folder the `sources` go into; `null` when that depends on what `dest` is.
15   */
16  | {
17      kind: 'overwrite'
18      tool: string
19      cwd: string[]
20      isCwdKnown: boolean
21      dest: Word
22      sources: Word[]
23      intoDir: boolean | null
24      isOnlyNonEmpty: boolean
25    }
26  /** `git reset --hard`, `restore`, `checkout -- ...`: `argv` lists the changed files (repo-relative, `rootArgv` finds the repo). */
27  | {
28      kind: 'git-discard'
29      cwd: string[]
30      isCwdKnown: boolean
31      scope: 'worktree' | 'all'
32      argv: string[] | null
33      rootArgv: string[]
34      text: string
35      note?: string
36    }
37  | { kind: 'opaque'; text: string; why: string }
38
39export type Analysis = {
40  found: Found[]
41  /** True when every part of the command is a delete (or a `cd`): nothing else would run. */
42  isOnlyDeletes: boolean
43}
44
45/** `write` empties the file (`>`, `>|`, `&>`), `dup` is `>&` (a file, or a descriptor like `2>&1`). */
46type Redirect = 'write' | 'append' | 'dup' | 'read' | 'heredoc'
47
48type Token = { type: 'word'; word: Word } | { type: 'op'; op: string } | { type: 'redirect'; mode: Redirect }
49
50const SPECIAL = /[\\*?[\]{}~]/g
51const isGlobPattern = (pattern: string) => /(^|[^\\])(\\\\)*[*?[]/.test(pattern)
52
53/** Index of the `)` that closes the `(` at `open`, skipping quotes; -1 when unclosed. */
54const closing = (src: string, open: number) => {
55  let depth = 0
56  for (let i = open; i < src.length; i++) {
57    const c = src[i]
58    if (c === '\\') i++
59    else if (c === "'") {
60      const end = src.indexOf("'", i + 1)
61      if (end < 0) return -1
62      i = end
63    } else if (c === '"') {
64      i++
65      while (i < src.length && src[i] !== '"') i += src[i] === '\\' ? 2 : 1
66    } else if (c === '(') depth++
67    else if (c === ')' && --depth === 0) return i
68  }
69  return -1
70}
71
72/** The `$(...)` and backtick commands in a text that is not split into words (an unquoted here-doc body). */
73const substitutions = (body: string): string[] => {
74  const out: string[] = []
75  for (let j = 0; j < body.length; j++) {
76    if (body[j] === '\\') j++
77    else if (body[j] === '$' && body[j + 1] === '(') {
78      const end = closing(body, j + 1)
79      out.push(body.slice(j + 2, end < 0 ? body.length : end))
80      j = end < 0 ? body.length : end
81    } else if (body[j] === '`') {
82      const end = body.indexOf('`', j + 1)
83      out.push(body.slice(j + 1, end < 0 ? body.length : end))
84      j = end < 0 ? body.length : end
85    }
86  }
87  return out
88}
89
90/** Splits `src` into words and operators; command substitutions go to `inner` as well. Here-doc bodies are text, not commands. */
91export const lex = (src: string): { tokens: Token[]; inner: string[] } => {
92  const tokens: Token[] = []
93  const inner: string[] = []
94  const heredocs: { delim: string; isStripped: boolean; isQuoted: boolean }[] = []
95  let cur: Word | null = null
96  const begin = (): Word => (cur ??= { text: '', pattern: '', isDynamic: false })
97  const flush = () => {
98    if (cur !== null) tokens.push({ type: 'word', word: cur })
99    cur = null
100  }
101  const lit = (s: string) => {
102    const w = begin()
103    w.text += s
104    w.pattern += s.replace(SPECIAL, '\\$&')
105  }
106  const dynamic = (raw: string) => {
107    const w = begin()
108    w.text += raw
109    w.pattern += raw.replace(SPECIAL, '\\$&')
110    w.isDynamic = true
111  }
112
113  // `$...` or a backtick at i: reads to its end, returns the index after it
114  const dollar = (i: number): number => {
115    if (src[i] === '`') {
116      let end = i + 1
117      while (end < src.length && src[end] !== '`') end += src[end] === '\\' ? 2 : 1
118      inner.push(src.slice(i + 1, end))
119      dynamic(src.slice(i, end + 1))
120      return end + 1
121    }
122    const n = src[i + 1]
123    if (n === '(') {
124      const end = closing(src, i + 1)
125      const stop = end < 0 ? src.length : end + 1
126      if (src[i + 2] !== '(') inner.push(src.slice(i + 2, stop - 1))
127      // `$(( $(cmd) ))` runs cmd too
128      else inner.push(...substitutions(src.slice(i + 3, stop - 2)))
129      dynamic(src.slice(i, stop))
130      return stop
131    }
132    if (n === '{') {
133      const end = closing(src.replace(/[{}]/g, c => (c === '{' ? '(' : ')')), i + 1)
134      const stop = end < 0 ? src.length : end + 1
135      // `${X:-$(cmd)}` runs cmd when X is unset
136      inner.push(...substitutions(src.slice(i + 2, stop - 1)))
137      dynamic(src.slice(i, stop))
138      return stop
139    }
140    if (n === "'") {
141      let end = i + 2
142      let text = ''
143      while (end < src.length && src[end] !== "'") {
144        if (src[end] === '\\' && end + 1 < src.length) {
145          const e = src[end + 1]
146          text += e === 'n' ? '\n' : e === 't' ? '\t' : e
147          end += 2
148        } else text += src[end++]
149      }
150      lit(text)
151      return end + 1
152    }
153    const name = /^(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?$!-])/.exec(src.slice(i + 1))
154    if (name === null) {
155      lit('$')
156      return i + 1
157    }
158    dynamic(src.slice(i, i + 1 + name[0].length))
159    return i + 1 + name[0].length
160  }
161
162  let i = 0
163  while (i < src.length) {
164    const c = src[i]!
165    if (c === ' ' || c === '\t' || c === '\r') {
166      flush()
167      i++
168    } else if (c === '\\') {
169      if (src[i + 1] !== '\n') lit(src[i + 1] ?? '')
170      i += 2
171    } else if (c === '#' && cur === null) {
172      while (i < src.length && src[i] !== '\n') i++
173    } else if (c === "'") {
174      const end = src.indexOf("'", i + 1)
175      lit(end < 0 ? src.slice(i + 1) : src.slice(i + 1, end))
176      i = end < 0 ? src.length : end + 1
177    } else if (c === '"') {
178      begin()
179      i++
180      while (i < src.length && src[i] !== '"') {
181        if (src[i] === '\\' && '"\\$`\n'.includes(src[i + 1] ?? '')) {
182          if (src[i + 1] !== '\n') lit(src[i + 1]!)
183          i += 2
184        } else if (src[i] === '$' || src[i] === '`') i = dollar(i)
185        else lit(src[i++]!)
186      }
187      i++
188    } else if (c === '$' || c === '`') {
189      i = dollar(i)
190    } else if (c === '>' || c === '<') {
191      const w = cur as Word | null
192      if (w !== null && /^\d+$/.test(w.text) && w.text === w.pattern) cur = null
193      flush()
194      const op = /^(<<<|<<-?|<>|>>|>\||>&|<&|>|<)/.exec(src.slice(i))![0]
195      if ((op === '<' || op === '>') && src[i + 1] === '(') {
196        tokens.push({ type: 'op', op: '(' })
197        i += 2
198      } else {
199        const mode: Redirect =
200          op === '>' || op === '>|' ? 'write' : op === '>&' ? 'dup' : op === '>>' ? 'append' : op === '<<' || op === '<<-' ? 'heredoc' : 'read'
201        tokens.push({ type: 'redirect', mode })
202        i += op.length
203        if (mode === 'heredoc') {
204          // the delimiter is still lexed as the next word; its body starts after the line ends
205          const d = /^[ \t]*(?:'([^']*)'|"([^"]*)"|(\\?)([^\s;&|<>()]+))/.exec(src.slice(i))
206          if (d !== null)
207            heredocs.push({
208              delim: d[1] ?? d[2] ?? d[4]!.replace(/\\/g, ''),
209              isStripped: op === '<<-',
210              isQuoted: d[1] !== undefined || d[2] !== undefined || d[3] === '\\' || d[4]!.includes('\\'),
211            })
212        }
213      }
214    } else if (c === '&' && src[i + 1] === '>') {
215      flush()
216      tokens.push({ type: 'redirect', mode: src[i + 2] === '>' ? 'append' : 'write' })
217      i += src[i + 2] === '>' ? 3 : 2
218    } else if (';&|()\n'.includes(c)) {
219      flush()
220      const op = /^(;;|&&|\|\||\|&|[;&|()\n])/.exec(src.slice(i))![0]
221      tokens.push({ type: 'op', op })
222      i += op.length
223      // skip the bodies of the here-docs opened on the line that just ended
224      if (op === '\n')
225        for (const h of heredocs.splice(0)) {
226          let body = ''
227          while (i < src.length) {
228            const eol = src.indexOf('\n', i)
229            const line = src.slice(i, eol < 0 ? src.length : eol)
230            i = eol < 0 ? src.length : eol + 1
231            if ((h.isStripped ? line.replace(/^\t+/, '') : line) === h.delim) break
232            body += `${line}\n`
233          }
234          if (!h.isQuoted) inner.push(...substitutions(body))
235        }
236    } else {
237      const w = begin()
238      w.text += c
239      w.pattern += c
240      i++
241    }
242  }
243  flush()
244  return { tokens, inner }
245}
246
247const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', '!', '{', '}', 'time'])
248const DELETERS = new Set(['rm', 'unlink', 'rmdir', 'shred', 'srm', 'trash', 'trash-put', 'rimraf'])
249const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
250const SAFE_EXTRA = new Set(['cd', 'pushd', 'popd', 'true', ':'])
251const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
252
253const baseName = (s: string) => s.slice(s.lastIndexOf('/') + 1)
254const isFlag = (w: Word) => w.text.length > 1 && w.text.startsWith('-')
255const literal = (text: string): Word => ({ text, pattern: text.replace(SPECIAL, '\\$&'), isDynamic: false })
256const splitWords = (src: string) => lex(src).tokens.flatMap(t => (t.type === 'word' ? [t.word] : []))
257
258/** A command named bare (found on PATH) or by a system path; `./rm` or `/tmp/x/git` are something else. */
259const isSystemPath = (s: string) => !s.includes('/') || /^\/(usr\/)?s?bin\/[^/]+$/.test(s)
260
261/**
262 * The flag a word sets and its glued value, if any: `--name=value`, or in a
263 * group of short flags (`-nu root`, `-uroot`, `-S'rm x'`) the first one that
264 * takes a value, with the rest of the group as that value.
265 */
266const flagValue = (w: Word, withArg: string[]): [string, Word | undefined] => {
267  if (w.text.startsWith('--')) return splitFlag(w)
268  for (let k = 1; k < w.text.length; k++) {
269    const flag = `-${w.text[k]}`
270    if (!withArg.includes(flag)) continue
271    const rest = w.text.slice(k + 1)
272    return [flag, rest === '' ? undefined : { text: rest, pattern: rest.replace(SPECIAL, '\\$&'), isDynamic: w.isDynamic }]
273  }
274  return [w.text, undefined]
275}
276
277/** Splits `--name=value` into the flag and its value (a word of its own). */
278const splitFlag = (w: Word): [string, Word | undefined] => {
279  const at = w.text.indexOf('=')
280  if (!w.text.startsWith('--') || at < 0) return [w.text, undefined]
281  return [w.text.slice(0, at), { ...w, text: w.text.slice(at + 1), pattern: w.pattern.slice(w.pattern.indexOf('=') + 1) }]
282}
283
284/** Commands that run the command after them, with their flags that take a value. */
285const WRAPPERS: Record<string, string[]> = {
286  sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T', '--user', '--group', '--close-from', '--chdir', '--host', '--prompt', '--role', '--type', '--other-user', '--command-timeout'],
287  doas: ['-u', '-C'],
288  env: ['-u', '-C', '-S', '--unset', '--chdir', '--split-string'],
289  nice: ['-n', '--adjustment'],
290  ionice: ['-c', '-n', '-p', '--class', '--classdata', '--pid'],
291  timeout: ['-s', '-k', '--signal', '--kill-after'],
292  stdbuf: ['-i', '-o', '-e', '--input', '--output', '--error'],
293  exec: ['-a'],
294  command: [],
295  builtin: [],
296  nohup: [],
297  caffeinate: ['-t', '-w'],
298  npx: ['-p', '--package', '-c', '--call', '-w', '--workspace'],
299  bunx: ['-p', '--package'],
300  pnpx: ['-p', '--package'],
301}
302/** `npm exec rimraf x` and the like run a package's command, as `npx` does. */
303const RUNNERS: Record<string, string[]> = { npm: ['exec', 'x'], pnpm: ['exec', 'dlx'], yarn: ['exec', 'dlx'], bun: ['x'] }
304
305/**
306 * Drops wrappers that run the command after them (`sudo`, `env`, `npx`, ...).
307 * `chdir` holds the folders a wrapper moves that one command into (`env -C`).
308 */
309const unwrap = (raw: Word[]): { words: Word[]; chdir: Word[]; isPlain: boolean } => {
310  let rest = raw
311  const chdir: Word[] = []
312  let isPlain = true
313  for (;;) {
314    while (rest.length > 0 && (KEYWORDS.has(rest[0]!.text) || ASSIGNMENT.test(rest[0]!.text))) {
315      // `PATH=… rm`, `LD_PRELOAD=… rm`: what runs is not the plain command
316      if (ASSIGNMENT.test(rest[0]!.text)) isPlain = false
317      rest = rest.slice(1)
318    }
319    if (rest.length === 0) return { words: rest, chdir, isPlain }
320    let name = baseName(rest[0]!.text)
321    if (RUNNERS[name]?.includes(rest[1]?.text ?? '')) {
322      rest = rest.slice(1)
323      name = 'npx'
324    }
325    const withArg = WRAPPERS[name]
326    if (withArg === undefined) return { words: rest, chdir, isPlain }
327    // a package runner fetches and runs code; `./sudo` is not sudo
328    if (name === 'npx' || name === 'bunx' || name === 'pnpx' || !isSystemPath(rest[0]!.text)) isPlain = false
329    rest = rest.slice(1)
330    while (rest.length > 0 && (isFlag(rest[0]!) || (name === 'env' && rest[0]!.text.includes('=')))) {
331      const w = rest[0]!
332      rest = rest.slice(1)
333      if (w.text === '--') break
334      if (!isFlag(w)) {
335        isPlain = false
336        continue
337      }
338      // `command -v rm` only looks the command up
339      if (name === 'command' && /^-[a-zA-Z]*[vV]/.test(w.text)) return { words: [], chdir, isPlain }
340      const [flag, glued] = flagValue(w, withArg)
341      if (!withArg.includes(flag)) continue
342      const value = glued ?? rest[0]
343      if (glued === undefined) rest = rest.slice(1)
344      if (value === undefined) continue
345      if (flag === '--chdir' || (name === 'env' && flag === '-C') || (name === 'sudo' && flag === '-D')) chdir.push(value)
346      else if (name === 'env' && (flag === '-S' || flag === '--split-string')) rest = [...splitWords(value.text), ...rest]
347      else if (name === 'npx' && (flag === '-c' || flag === '--call')) rest = [literal('sh'), literal('-c'), value]
348    }
349    if (name === 'timeout') rest = rest.slice(1)
350  }
351}
352
353/** The operands after the flags; `--` ends the flags. `withArg` names flags that take the next word. */
354const operands = (args: Word[], withArg: string[] = []) => {
355  const out: Word[] = []
356  let isFlagsDone = false
357  for (let i = 0; i < args.length; i++) {
358    const w = args[i]!
359    if (!isFlagsDone && w.text === '--') isFlagsDone = true
360    else if (!isFlagsDone && isFlag(w)) i += withArg.includes(w.text) ? 1 : 0
361    else out.push(w)
362  }
363  return out
364}
365
366/** What a found part does: only these count as a delete. */
367const isDelete = (f: Found) => f.kind === 'paths' || f.kind === 'find' || f.kind === 'git-clean' || f.kind === 'opaque'
368
369/** Reads a command that another one runs (`find -exec`, `xargs`) on its own. */
370const subCommand = (words: Word[], ctx: Ctx, isCwdKnown = ctx.isCwdKnown): Ctx => {
371  const sub: Ctx = { cwd: [...ctx.cwd], isCwdKnown, found: [], others: 0, depth: ctx.depth + 1 }
372  if (ctx.depth < 4) readCommand(words, sub)
373  else sub.others++
374  return sub
375}
376
377/** Takes the stand-in for `{}` or xargs' input back out of what a sub-command found. */
378const strip = (found: Found[], stand: Word): Found[] =>
379  found.flatMap((f): Found[] => {
380    if (f.kind === 'paths') {
381      const words = f.words.filter(w => w !== stand)
382      return words.length > 0 ? [{ ...f, words }] : []
383    }
384    if (f.kind === 'overwrite') return f.dest === stand ? [] : [{ ...f, sources: f.sources.filter(w => w !== stand) }]
385    return [f]
386  })
387
388const FIND_ACTIONS = ['-exec', '-execdir', '-ok', '-okdir']
389const FIND_WRITES = /^-(fprint0?|fprintf|fls)$/
390
391/**
392 * `find` with `-delete` or `-exec rm`: the same `find` with each delete swapped
393 * in place for `-print` lists what it would delete, in the same order of tests.
394 */
395const readFind = (args: Word[], ctx: Ctx): { found: Found[]; isOnlyDeletes: boolean } | null => {
396  let i = 0
397  const opts: string[] = []
398  while (i < args.length && /^-[HLP]$|^-D$|^-O\d$/.test(args[i]!.text)) {
399    opts.push(args[i]!.text)
400    if (args[i]!.text === '-D') opts.push(args[++i]?.text ?? '')
401    i++
402  }
403  const roots: Word[] = []
404  while (i < args.length && !/^[-(!]/.test(args[i]!.text)) roots.push(args[i++]!)
405  const expr = args.slice(i)
406  const preview: string[] = []
407  const extra: Found[] = []
408  let deletes = false
409  let hasDelete = false
410  let isOther = false
411  let isUnlistable = roots.some(w => w.isDynamic)
412  for (let j = 0; j < expr.length; j++) {
413    const w = expr[j]!
414    if (w.text === '-delete') {
415      deletes = hasDelete = true
416      preview.push('-print')
417      continue
418    }
419    if (FIND_ACTIONS.includes(w.text)) {
420      const cmd: Word[] = []
421      while (++j < expr.length && expr[j]!.text !== ';' && !(expr[j]!.text === '+' && expr[j - 1]?.text === '{}')) cmd.push(expr[j]!)
422      const stand: Word = { text: '{}', pattern: '{}', isDynamic: true }
423      const sub = subCommand(
424        cmd.map(c => (c.text === '{}' ? stand : c)),
425        ctx,
426        ctx.isCwdKnown && !w.text.endsWith('dir'),
427      )
428      if (sub.found.some(isDelete)) {
429        deletes = true
430        preview.push('-print')
431        extra.push(...strip(sub.found, stand))
432        if (sub.others > 0) isOther = true
433      } else isOther = isUnlistable = true
434      continue
435    }
436    if (FIND_WRITES.test(w.text)) isOther = isUnlistable = true
437    if (/^-(ls|printf|print0)$/.test(w.text) || w.isDynamic) isUnlistable = true
438    // the command's own -print would list what is not deleted too; -true keeps the logic
439    preview.push(w.text === '-print' ? '-true' : w.text)
440  }
441  if (!deletes) return null
442  const text = ['find', ...args.map(w => w.text)].join(' ')
443  const argv = isUnlistable
444    ? null
445    : ['find', ...opts, ...(roots.length > 0 ? roots.map(w => w.text) : ['.']), ...(hasDelete ? ['-depth'] : []), ...preview]
446  return { found: [{ kind: 'find', cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown, argv, text }, ...extra], isOnlyDeletes: !isOther }
447}
448
449/** Global git flags that take the next word. */
450const GIT_WITH_ARG = ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix', '--exec-path']
451
452/** A subcommand's options as git reads them: values taken (`-e -n` is an exclude, not a dry run), long names abbreviated. */
453type GitOptions = { short: Set<string>; long: string[]; values: [string, string][]; operands: Word[]; afterDashes: Word[] | null }
454
455const gitOptions = (rest: Word[], shortWithValue = '', longWithValue: string[] = []): GitOptions => {
456  const o: GitOptions = { short: new Set(), long: [], values: [], operands: [], afterDashes: null }
457  const longs = [...longWithValue, '--pathspec-from-file']
458  for (let i = 0; i < rest.length; i++) {
459    const w = rest[i]!
460    if (w.text === '--') {
461      o.afterDashes = rest.slice(i + 1)
462      break
463    }
464    if (w.text.startsWith('--')) {
465      const [flag, glued] = splitFlag(w)
466      const full = longs.find(l => flag.length >= 4 && l.startsWith(flag))
467      if (full === undefined) o.long.push(flag)
468      else o.values.push([full, glued?.text ?? rest[++i]?.text ?? ''])
469      continue
470    }
471    if (/^-[a-zA-Z]/.test(w.text)) {
472      for (let k = 1; k < w.text.length; k++) {
473        const c = w.text[k]!
474        if (!shortWithValue.includes(c)) {
475          o.short.add(c)
476          continue
477        }
478        o.values.push([`-${c}`, k + 1 < w.text.length ? w.text.slice(k + 1) : (rest[++i]?.text ?? '')])
479        break
480      }
481      continue
482    }
483    o.operands.push(w)
484  }
485  return o
486}
487
488/** True when the options name `full`, written out or abbreviated as git allows (`--dry` for `--dry-run`). */
489const hasLong = (o: GitOptions, full: string) => o.long.some(l => l.length >= 4 && full.startsWith(l))
490
491/**
492 * `git rm`, `git clean` and the commands that throw away uncommitted changes.
493 * A dry run never takes the command's own `-c` or other global flags (a config
494 * value such as `core.fsmonitor` would run code before anyone answered), and
495 * never runs against a repository named by `--git-dir`/`--work-tree`.
496 */
497const readGit = (args: Word[], ctx: Ctx, hasGitEnv: boolean): Found | null => {
498  const cwd = [...ctx.cwd]
499  let isCwdKnown = ctx.isCwdKnown
500  let isUnlistable = hasGitEnv
501  let i = 0
502  while (i < args.length && isFlag(args[i]!)) {
503    const [flag, glued] = splitFlag(args[i]!)
504    const value = GIT_WITH_ARG.includes(flag) && flag !== '--exec-path' ? (glued ?? args[++i]) : undefined
505    if (flag === '-C') {
506      if (value === undefined || value.isDynamic) isCwdKnown = false
507      else cwd.push(value.text)
508    } else if (flag === '--git-dir' || flag === '--work-tree') isUnlistable = true
509    i++
510  }
511  const sub = args[i]?.text
512  const rest = args.slice(i + 1)
513  const text = ['git', ...args.map(w => w.text)].join(' ')
514  const git = ['git', '-c', 'core.fsmonitor=false', '--no-optional-locks']
515  const rootArgv = [...git, 'rev-parse', '--show-toplevel']
516  const opaque = (why: string): Found => ({ kind: 'opaque', text, why })
517
518  if (sub === 'rm') {
519    const o = gitOptions(rest)
520    if (o.values.some(([name]) => name === '--pathspec-from-file')) return opaque('removes the paths listed in a file')
521    if (o.short.has('n') || hasLong(o, '--dry-run') || hasLong(o, '--cached')) return null
522    const words = [...o.operands, ...(o.afterDashes ?? [])]
523    return words.length === 0 ? null : { kind: 'paths', tool: 'git rm', cwd, isCwdKnown, words }
524  }
525  if (sub === 'clean') {
526    const o = gitOptions(rest, 'e', ['--exclude'])
527    if (o.short.has('n') || hasLong(o, '--dry-run')) return null
528    const paths = [...o.operands, ...(o.afterDashes ?? [])]
529    if (isUnlistable || args.some(w => w.isDynamic)) return { kind: 'git-clean', cwd, isCwdKnown, argv: null, rootArgv, text }
530    // only the flags that change what is listed; anything else (`--interactive`, abbreviated or not) stays out
531    const flags = [
532      ...['d', 'x', 'X'].filter(c => o.short.has(c)).map(c => `-${c}`),
533      ...o.values.filter(([name]) => name === '-e' || name === '--exclude').map(([, v]) => `--exclude=${v}`),
534    ]
535    return { kind: 'git-clean', cwd, isCwdKnown, argv: [...git, 'clean', '-n', ...flags, '--', ...paths.map(w => w.text)], rootArgv, text }
536  }
537  if (sub === 'stash' && (rest[0]?.text === 'drop' || rest[0]?.text === 'clear')) return opaque('throws away stashed changes')
538  if (sub === 'worktree' && rest[0]?.text === 'remove' && rest.some(w => w.text === '-f' || w.text === '--force'))
539    return opaque('removes a worktree together with its uncommitted changes')
540
541  const discard = (paths: Word[], scope: 'worktree' | 'all', note?: string): Found => ({
542    kind: 'git-discard',
543    cwd,
544    isCwdKnown,
545    scope,
546    text,
547    ...(note === undefined ? {} : { note }),
548    argv:
549      isUnlistable || paths.some(w => w.isDynamic)
550        ? null
551        : [...git, 'status', '--porcelain=v1', '-z', '--untracked-files=no', '--', ...paths.map(w => w.text)],
552    rootArgv,
553  })
554  if (sub === 'reset') {
555    const o = gitOptions(rest)
556    if (!hasLong(o, '--hard')) return null
557    const rev = o.operands[0]
558    return discard(
559      [],
560      'all',
561      rev === undefined || rev.text === 'HEAD' ? undefined : `moves the branch to ${rev.text}: commits after it stay reachable only through the reflog`,
562    )
563  }
564  if (sub === 'restore') {
565    const o = gitOptions(rest, 's', ['--source'])
566    if (o.values.some(([name]) => name === '--pathspec-from-file')) return opaque('restores the paths listed in a file')
567    const isStaged = o.short.has('S') || hasLong(o, '--staged')
568    const isWorktree = o.short.has('W') || hasLong(o, '--worktree')
569    if (isStaged && !isWorktree) return null
570    const hasSource = o.values.some(([name]) => name === '-s' || name === '--source')
571    const paths = [...o.operands, ...(o.afterDashes ?? [])]
572    return paths.length === 0 ? null : discard(paths, isStaged || hasSource ? 'all' : 'worktree')
573  }
574  if (sub === 'checkout') {
575    const o = gitOptions(rest, 'bB', ['--orphan', '--conflict'])
576    if (o.values.some(([name]) => name === '--pathspec-from-file')) return opaque('restores the paths listed in a file')
577    const isForce = o.short.has('f') || hasLong(o, '--force')
578    if (o.afterDashes !== null) return o.afterDashes.length === 0 ? null : discard(o.afterDashes, o.operands.length > 0 ? 'all' : 'worktree')
579    if (isForce) return discard([], 'all')
580    // `git checkout name`: a file of that name loses its changes; a branch name lists nothing and is not asked about
581    if (o.operands.length > 0 && !o.values.some(([name]) => /^-[bB]$|^--orphan$/.test(name)))
582      return discard(o.operands, o.operands.length > 1 ? 'all' : 'worktree')
583    return null
584  }
585  if (sub === 'switch') {
586    const o = gitOptions(rest, 'cC', ['--create', '--force-create', '--orphan'])
587    return o.short.has('f') || hasLong(o, '--force') || hasLong(o, '--discard-changes') ? discard([], 'all') : null
588  }
589  return null
590}
591
592/**
593 * `truncate` keeps the file but cuts its content: what it does to each file, as
594 * `change`. Only growing it (`+N`, `>N`, `%N`) loses nothing and is left alone.
595 */
596const readTruncate = (args: Word[], ctx: Ctx): Found | null => {
597  let size: string | undefined
598  let ref: string | undefined
599  const words: Word[] = []
600  let isFlagsDone = false
601  for (let i = 0; i < args.length; i++) {
602    const w = args[i]!
603    const t = w.text
604    if (isFlagsDone || !isFlag(w)) words.push(w)
605    else if (t === '--') isFlagsDone = true
606    else if (t === '--size' || t === '--reference' || /^-[a-z]*[sr]$/.test(t)) {
607      const value = args[++i]?.text ?? ''
608      if (t === '--size' || t.endsWith('s')) size = value
609      else ref = value
610    } else if (t.startsWith('--size=')) size = t.slice(7)
611    else if (t.startsWith('--reference=')) ref = t.slice(12)
612    else {
613      const glued = /^-[a-z]*?([sr])(.+)$/.exec(t)
614      if (glued?.[1] === 's') size = glued[2]
615      else if (glued?.[1] === 'r') ref = glued[2]
616    }
617  }
618  if (words.length === 0 || (size === undefined && ref === undefined)) return null
619  if (size !== undefined && /^[+>%]/.test(size)) return null
620  const change =
621    size === undefined
622      ? `size set to that of ${ref}`
623      : /^0+$/.test(size)
624        ? 'emptied, the file stays'
625        : size.startsWith('-')
626          ? `shortened by ${size.slice(1)}`
627          : size.startsWith('<')
628            ? `cut to at most ${size.slice(1)}`
629            : size.startsWith('/')
630              ? `cut to a multiple of ${size.slice(1)}`
631              : `size set to ${size}`
632  return { kind: 'paths', tool: 'truncate', cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown, words, change }
633}
634
635/** Flags of `cp`, `mv` and `install` that take a value; the short ones may end a group (`-rt dir`). */
636const COPY_WITH_ARG: Record<string, string[]> = {
637  cp: ['-t', '--target-directory', '-S', '--suffix'],
638  mv: ['-t', '--target-directory', '-S', '--suffix'],
639  install: ['-t', '--target-directory', '-S', '--suffix', '-m', '--mode', '-o', '--owner', '-g', '--group', '--strip-program'],
640  ln: ['-t', '--target-directory', '-S', '--suffix'],
641}
642
643/** `cp`, `mv`, `install`, `ln -f`: the file or folder they write to. Not when they keep what is there (`-n`, `-i`, `-b`). */
644const readCopy = (name: string, args: Word[], ctx: Ctx): Found | null => {
645  const withArg = COPY_WITH_ARG[name]!
646  const keeps = name === 'install' ? /[bd]/ : name === 'ln' ? /[ib]/ : /[nib]/
647  // `ln` replaces an existing name only with -f
648  if (name === 'ln' && !args.some(w => w.text === '--force' || /^-[a-zA-Z]*f/.test(w.text))) return null
649  const files: Word[] = []
650  let target: Word | undefined
651  let isNoTarget = false
652  let isFlagsDone = false
653  for (let i = 0; i < args.length; i++) {
654    const w = args[i]!
655    if (isFlagsDone || !isFlag(w)) {
656      files.push(w)
657      continue
658    }
659    if (w.text === '--') {
660      isFlagsDone = true
661      continue
662    }
663    const [flag, glued] = splitFlag(w)
664    if (['--no-clobber', '--interactive', '--backup', '--directory'].includes(flag) || (flag === '--update' && glued?.text.startsWith('none')))
665      return null
666    if (flag === '--no-target-directory') isNoTarget = true
667    if (flag.startsWith('--')) {
668      if (withArg.includes(flag)) {
669        const value = glued ?? args[++i]
670        if (flag === '--target-directory') target = value
671      }
672      continue
673    }
674    const letters = flag.slice(1)
675    if (keeps.test(letters)) return null
676    if (letters.includes('T')) isNoTarget = true
677    const last = `-${letters.at(-1)}`
678    if (withArg.includes(last)) {
679      const value = args[++i]
680      if (last === '-t') target = value
681    }
682  }
683  const dest = target ?? (files.length >= 2 ? files.pop() : undefined)
684  if (dest === undefined) return null
685  return {
686    kind: 'overwrite',
687    tool: name,
688    cwd: [...ctx.cwd],
689    isCwdKnown: ctx.isCwdKnown,
690    dest,
691    sources: files,
692    intoDir: isNoTarget ? false : target !== undefined || files.length > 1 ? true : null,
693    isOnlyNonEmpty: false,
694  }
695}
696
697type Ctx = { cwd: string[]; isCwdKnown: boolean; found: Found[]; others: number; depth: number }
698
699/** A file the command writes from the start: asked about only if it holds something now. */
700const overwrite = (tool: string, dest: Word, ctx: Ctx, isOnlyNonEmpty = true): Found => ({
701  kind: 'overwrite',
702  tool,
703  cwd: [...ctx.cwd],
704  isCwdKnown: ctx.isCwdKnown,
705  dest,
706  sources: [],
707  intoDir: false,
708  isOnlyNonEmpty,
709})
710
711/** One simple command: what it deletes goes to `ctx.found`, a `cd` moves `ctx.cwd`. */
712const readCommand = (raw: Word[], ctx: Ctx) => {
713  const { words, chdir, isPlain } = unwrap(raw)
714  if (!isPlain) ctx.others++
715  if (words.length === 0) {
716    if (raw.some(w => !KEYWORDS.has(w.text))) ctx.others++
717    return
718  }
719  if (chdir.length === 0) return readSimple(words, ctx, raw)
720  const saved = { cwd: ctx.cwd, isCwdKnown: ctx.isCwdKnown }
721  for (const dir of chdir) {
722    if (dir.isDynamic) ctx.isCwdKnown = false
723    else ctx.cwd = [...ctx.cwd, dir.text]
724  }
725  try {
726    readSimple(words, ctx, raw)
727  } finally {
728    ctx.cwd = saved.cwd
729    ctx.isCwdKnown = saved.isCwdKnown
730  }
731}
732
733const readSimple = (words: Word[], ctx: Ctx, raw: Word[]) => {
734  const head = words[0]!
735  const name = baseName(head.text)
736  const args = words.slice(1)
737  const text = words.map(w => w.text).join(' ')
738  if (head.isDynamic) {
739    ctx.others++
740    return
741  }
742  // `./rm` is read as rm, but it is some other program: never only a delete
743  if (!isSystemPath(head.text)) ctx.others++
744
745  if (name === 'cd' || name === 'pushd') {
746    const dir = operands(args)[0]
747    if (dir === undefined) ctx.cwd.push('~')
748    else if (dir.isDynamic || dir.text === '-') ctx.isCwdKnown = false
749    else ctx.cwd.push(dir.text)
750    return
751  }
752  if (name === 'popd') {
753    ctx.isCwdKnown = false
754    return
755  }
756  if (DELETERS.has(name)) {
757    const words = operands(args, name === 'shred' ? ['-n', '-s', '--iterations', '--size', '--random-source'] : [])
758    if (words.length > 0) ctx.found.push({ kind: 'paths', tool: name, cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown, words })
759    return
760  }
761  if (name === 'trash-empty' || name === 'trash-rm' || (name === 'gio' && args[0]?.text === 'trash' && args.some(w => w.text === '--empty'))) {
762    ctx.found.push({ kind: 'opaque', text, why: name === 'trash-rm' ? 'removes matching files from the trash for good' : 'empties the trash for good' })
763    return
764  }
765  if (name === 'gio' && (args[0]?.text === 'trash' || args[0]?.text === 'remove')) {
766    const words = operands(args.slice(1))
767    if (words.length > 0) ctx.found.push({ kind: 'paths', tool: `gio ${args[0]!.text}`, cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown, words })
768    return
769  }
770  if (name === 'truncate') {
771    const found = readTruncate(args, ctx)
772    if (found !== null) ctx.found.push(found)
773    else ctx.others++
774    return
775  }
776  if (name === 'find') {
777    const found = readFind(args, ctx)
778    if (found !== null) ctx.found.push(...found.found)
779    if (found === null || !found.isOnlyDeletes) ctx.others++
780    return
781  }
782  if (name === 'git') {
783    const found = readGit(args, ctx, raw.some(w => /^GIT_[A-Z_]*=/.test(w.text)))
784    if (found !== null) ctx.found.push(found)
785    // `git -c core.fsmonitor=… clean` runs that code: only `-C dir` keeps it a plain delete
786    const isPlainGit = (() => {
787      for (let i = 0; i < args.length && isFlag(args[i]!); i++) if (args[i]!.text !== '-C' || ++i >= args.length) return false
788      return true
789    })()
790    if (found === null || !isDelete(found) || !isPlainGit) ctx.others++
791    return
792  }
793  if (name === 'cp' || name === 'mv' || name === 'install' || name === 'ln') {
794    const found = readCopy(name, args, ctx)
795    if (found !== null) ctx.found.push(found)
796    ctx.others++
797    return
798  }
799  if (name === 'tee') {
800    if (!args.some(w => w.text === '--append' || /^-[a-zA-Z]*a/.test(w.text)))
801      for (const w of operands(args)) ctx.found.push(overwrite('tee', w, ctx))
802    ctx.others++
803    return
804  }
805  if (name === 'dd') {
806    const of = args.find(w => w.text.startsWith('of='))
807    if (of !== undefined) {
808      const dest: Word = { ...of, text: of.text.slice(3), pattern: of.pattern.slice(3) }
809      if (dest.text.startsWith('/dev/') && !/^\/dev\/(null|zero|stdout|stderr|fd\/)/.test(dest.text))
810        ctx.found.push({ kind: 'opaque', text, why: `writes straight to the device ${dest.text}` })
811      else ctx.found.push(overwrite('dd', dest, ctx, false))
812    }
813    ctx.others++
814    return
815  }
816  if (name === 'xargs') {
817    let i = 0
818    const withArg = ['-I', '-n', '-P', '-L', '-d', '-s', '-a', '-E', '-J', '-R', '-S']
819    const longWithArg = ['--max-args', '--max-procs', '--max-lines', '--delimiter', '--max-chars', '--arg-file', '--eof', '--process-slot-var']
820    while (i < args.length && isFlag(args[i]!)) {
821      const t = args[i]!.text
822      if (t === '--') {
823        i++
824        break
825      }
826      i += withArg.includes(t) || longWithArg.includes(t) ? 2 : 1
827    }
828    const stand: Word = { text: '{}', pattern: '{}', isDynamic: true }
829    const sub = subCommand([...args.slice(i), stand], ctx)
830    if (sub.found.some(isDelete)) {
831      ctx.found.push({ kind: 'opaque', text, why: 'deletes whatever the command before it lists' }, ...strip(sub.found, stand))
832    } else ctx.others++
833    return
834  }
835  if (name === 'rsync') {
836    if (args.some(w => /^--(del|delete(-[a-z]+)?|remove-(source|sent)-files)(=|$)/.test(w.text)))
837      ctx.found.push({ kind: 'opaque', text, why: 'removes files at the destination that the source lacks' })
838    ctx.others++
839    return
840  }
841  if ((SHELLS.has(name) || name === 'eval') && ctx.depth < 4) {
842    const { script, isPlain } = name === 'eval' ? { script: args.map(w => w.text).join(' '), isPlain: true } : shellScript(args)
843    if (script !== undefined) {
844      const sub = analyze(script, { ...ctx, cwd: [...ctx.cwd], found: [], others: 0, depth: ctx.depth + 1 })
845      ctx.found.push(...sub.found)
846      if (!sub.isOnlyDeletes || !isPlain) ctx.others++
847      return
848    }
849  }
850  if (!SAFE_EXTRA.has(name)) ctx.others++
851}
852
853/** The script of `bash -c '...'`: `c` may sit in any flag group (`-lc`, `-cf`), `--` may come first. */
854const shellScript = (args: Word[]): { script: string | undefined; isPlain: boolean } => {
855  let hasC = false
856  // a login or interactive shell, or another rc file, runs more than the script
857  let isPlain = true
858  let i = 0
859  for (; i < args.length; i++) {
860    const t = args[i]!.text
861    if (t === '--' || t === '-') {
862      i++
863      break
864    }
865    if (/^[-+][a-zA-Z]+$/.test(t)) {
866      if (t.startsWith('-') && t.includes('c')) hasC = true
867      if (!/^[-+][ceuxvo]+$/.test(t)) isPlain = false
868      if (/[oO]$/.test(t) && !['pipefail', 'errexit', 'nounset', 'xtrace'].includes(args[++i]?.text ?? '')) isPlain = false
869    } else if (t.startsWith('--')) {
870      isPlain = false
871      if (t === '--rcfile' || t === '--init-file') i++
872    } else break
873  }
874  return { script: hasC ? args[i]?.text : undefined, isPlain }
875}
876
877const analyze = (command: string, ctx: Ctx): Analysis => {
878  const { tokens, inner } = lex(command)
879  const stack: { cwd: string[]; isCwdKnown: boolean }[] = []
880  let words: Word[] = []
881  let redirect: Redirect | null = null
882  let hasRedirect = false
883  const end = () => {
884    readCommand(words, ctx)
885    words = []
886  }
887  for (const t of tokens) {
888    if (t.type === 'word') {
889      // `> file` (and `>& file`, not `2>&1`) empties the file before anything runs
890      if (redirect === 'write' || (redirect === 'dup' && !/^(\d+|-)$/.test(t.word.text))) ctx.found.push(overwrite('>', t.word, ctx))
891      if (redirect !== null) redirect = null
892      else words.push(t.word)
893      continue
894    }
895    if (t.type === 'redirect') {
896      redirect = t.mode
897      hasRedirect = true
898      continue
899    }
900    end()
901    if (t.op === '(') stack.push({ cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown })
902    if (t.op === ')') Object.assign(ctx, stack.pop() ?? {})
903    if (t.op === '|' || t.op === '|&') ctx.others++
904  }
905  end()
906  for (const sub of inner) {
907    if (ctx.depth >= 4) break
908    const nested = analyze(sub, { ...ctx, cwd: [...ctx.cwd], found: [], others: 0, depth: ctx.depth + 1 })
909    ctx.found.push(...nested.found)
910    ctx.others++
911  }
912  return { found: ctx.found, isOnlyDeletes: ctx.others === 0 && !hasRedirect && ctx.found.every(f => f.kind !== 'opaque' && isDelete(f)) }
913}
914
915/** What the command would delete or overwrite, and whether deleting is all it does. */
916export const findDeletes = (command: string): Analysis =>
917  analyze(command, { cwd: [], isCwdKnown: true, found: [], others: 0, depth: 0 })
918
919/** More names than this from one word are not listed: the word counts as unknown. */
920const MAX_NAMES = 1000
921
922/** Expands `{a,b}` and `{1..3}` in an escaped pattern, as bash does before globbing; `null` past `MAX_NAMES`. */
923export const expandBraces = (pattern: string): string[] | null => {
924  try {
925    return braces(pattern)
926  } catch {
927    return null
928  }
929}
930
931const braces = (pattern: string): string[] => {
932  let depth = 0
933  let open = -1
934  for (let i = 0; i < pattern.length; i++) {
935    const c = pattern[i]
936    if (c === '\\') {
937      i++
938      continue
939    }
940    if (c === '{') {
941      if (depth++ === 0) open = i
942    } else if (c === '}' && depth > 0 && --depth === 0) {
943      const body = pattern.slice(open + 1, i)
944      const pre = pattern.slice(0, open)
945      const post = pattern.slice(i + 1)
946      const range = /^(-?\d+|[a-zA-Z])\.\.(-?\d+|[a-zA-Z])(?:\.\.(-?\d+))?$/.exec(body)
947      const isLetters = range !== null && /^[a-zA-Z]$/.test(range[1]!) && /^[a-zA-Z]$/.test(range[2]!)
948      const parts: string[] = []
949      if (range !== null && (isLetters || (/\d/.test(range[1]!) && /\d/.test(range[2]!)))) {
950        const [a, b] = isLetters ? [range[1]!.charCodeAt(0), range[2]!.charCodeAt(0)] : [Number(range[1]), Number(range[2])]
951        const step = Math.max(1, Math.abs(Number(range[3] ?? 1)))
952        if (Math.abs(b - a) / step >= MAX_NAMES) throw new RangeError('too many names')
953        // `{01..10}` pads to the wider end, as bash does
954        const width = !isLetters && (/^-?0\d/.test(range[1]!) || /^-?0\d/.test(range[2]!)) ? Math.max(range[1]!.length, range[2]!.length) : 0
955        const show = (n: number) =>
956          isLetters ? String.fromCharCode(n) : n < 0 ? `-${String(-n).padStart(width - 1, '0')}` : String(n).padStart(width, '0')
957        for (let n = a; a <= b ? n <= b : n >= b; n += a <= b ? step : -step) parts.push(show(n))
958      } else if (range !== null) {
959        // `{a..3}` is not a range to bash: the word stays as it is
960        return braces(post).map(p => `${pre}{${body}}${p}`)
961      } else {
962        let d = 0
963        let from = 0
964        for (let j = 0; j < body.length; j++) {
965          if (body[j] === '\\') j++
966          else if (body[j] === '{') d++
967          else if (body[j] === '}') d--
968          else if (body[j] === ',' && d === 0) {
969            parts.push(body.slice(from, j))
970            from = j + 1
971          }
972        }
973        if (parts.length === 0) return braces(post).map(p => `${pre}{${body}}${p}`)
974        parts.push(body.slice(from))
975      }
976      const out = parts.flatMap(part => braces(pre + part + post))
977      if (out.length > MAX_NAMES) throw new RangeError('too many names')
978      return out
979    }
980  }
981  return [pattern]
982}
983
984export const hasGlob = isGlobPattern
985
986/** Drops the escapes from a pattern without glob characters. */
987export const unescape = (pattern: string) => pattern.replace(/\\(.)/g, '$1')
988
989/** One path segment's glob as a RegExp; `*` and `?` skip a leading dot as bash does. */
990export const segmentRegex = (segment: string): RegExp => {
991  let out = ''
992  for (let i = 0; i < segment.length; i++) {
993    const c = segment[i]!
994    if (c === '\\') out += (segment[++i] ?? '').replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
995    else if (c === '*') out += '[^/]*'
996    else if (c === '?') out += '[^/]'
997    else if (c === '[') {
998      const end = segment.indexOf(']', i + 2)
999      if (end < 0) out += '\\['
1000      else {
1001        let body = segment.slice(i + 1, end)
1002        const isNegated = body.startsWith('!') || body.startsWith('^')
1003        if (isNegated) body = body.slice(1)
1004        out += `[${isNegated ? '^' : ''}${body.replace(/\\/g, '\\\\')}]`
1005        i = end
1006      }
1007    } else out += c.replace(/[.+^${}()|\\/]/g, '\\$&')
1008  }
1009  const dotSafe = segment.startsWith('.') || segment.startsWith('\\.') ? '' : '(?!\\.)'
1010  return new RegExp(`^${dotSafe}${out}$`)
1011}
1012
1013/** Joins and folds `.` and `..` (POSIX paths). */
1014export const joinPath = (base: string, path: string) => {
1015  const parts = (path.startsWith('/') ? path : `${base}/${path}`).split('/')
1016  const out: string[] = []
1017  for (const p of parts) {
1018    if (p === '' || p === '.') continue
1019    if (p === '..') out.pop()
1020    else out.push(p)
1021  }
1022  return `/${out.join('/')}`
1023}
1024
1025/** An absolute path with the home folder written `~`. */
1026export const tilde = (abs: string, home: string) =>
1027  home !== '' && (abs === home || abs.startsWith(`${home}/`)) ? `~${abs.slice(home.length)}` : abs
1028
1029/** A path for display: relative to `cwd` inside it, `~` for home, absolute otherwise. */
1030export const showPath = (abs: string, cwd: string, home: string) =>
1031  abs === cwd ? '.' : abs.startsWith(`${cwd}/`) ? abs.slice(cwd.length + 1) : tilde(abs, home)
1032
1033export const bytes = (n: number) =>
1034  n >= 1024 ** 3
1035    ? `${(n / 1024 ** 3).toFixed(1)} GB`
1036    : n >= 1024 ** 2
1037      ? `${(n / 1024 ** 2).toFixed(1)} MB`
1038      : n >= 1024
1039        ? `${Math.round(n / 1024)} KB`
1040        : `${n} B`
1041
types/index.d.ts 39 lines
1/** One thing the command would delete, as the panel lists it. */
2export type Entry = {
3  /** As shown: relative to the working directory inside it, `~/...` or absolute. */
4  path: string
5  kind: 'dir' | 'file' | 'link' | 'missing' | 'unknown'
6  /** Bytes of a file, or of everything under a folder. */
7  size: number
8  /** Files and folders under a folder (counted up to a limit: `isPartial`). */
9  files: number
10  dirs: number
11  isPartial: boolean
12  /** A few paths inside a folder, relative to it. */
13  sample: string[]
14  /** Why it is listed this way (`matches nothing`, `decided when the command runs`). */
15  note: string
16  /** What the command does to it when that is not a plain delete. */
17  action?: 'truncate' | 'overwrite' | 'discard'
18  /** Said beside it: `emptied, the file stays`, `overwritten by cp`. */
19  change?: string
20}
21
22/** A delete waiting for the person's answer. */
23export type Request = {
24  id: string
25  command: string
26  cwd: string
27  entries: Entry[]
28  /** Parts of the command whose targets can't be listed beforehand. */
29  notes: string[]
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'delete-guard': {
35      queue: Request[]
36    }
37  }
38}
39