SLOPSHOPPER

last-look

Holds destructive commands until you approve, and shows what they would destroy.

newpanebandguardstatusprocess
★ 1v0.1.3MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/last-look
A shopper browsing a rack in a slop shop
Preview could not run: the mod kept running for 20s in the preview session (a hold loop or a long wait); no drawing was captured
README

<img src="../../brand/pixel-icons/svg/last-look.svg" width="64" height="64" alt="">

Last Look

Holds destructive commands until you approve, and shows what they would destroy.

Install

/plugin install last-look --marketplace modmyclaude/claude-code-mods

Use it

There is nothing to run. When a shell command Claude is about to run (through the Bash tool, or the Monitor tool's command) is one of the destructive kinds below, Last Look holds it before it runs, measures what it would destroy, and asks you. Once a command is recognised as destructive it is always held: measuring only adds detail to the hold and never lets a command through.

  • Where it asks. A pane when the terminal has room for one (Claude Code places a pane nobody asked for from 144 columns: docked beside the transcript in fullscreen, inline above the prompt otherwise). On narrower terminals, a band directly above the prompt. The desktop app works the same way.
  • What it shows. The command, and for each destructive part of it: every file it would delete with the total size (cut to the room there is, then "and N more"), files with unsaved work highlighted first ("3 files have unsaved work you would lose"), and the commits or branch that would be lost. When its measurement comes back empty or fails, the hold says so ("Nothing found at these paths" for plain paths, otherwise "could not measure: review it yourself"); either way you decide.
  • Deciding. Keys you type never approve: the pane and the band never take the keyboard on their own, and Proceed has no hotkey. Click a button, or press ctrl+x tab to move into the pane or band; Cancel then has the focus, so Enter (or c) cancels. Proceed takes a click, or Tab onto it and then Enter. In the pane, Esc closes it and cancels. While a command is held in the band, the band shows only the hold (other mods' bands are hidden until you decide), so nothing another mod draws can hide its buttons.
  • After. Cancel tells Claude the command did not run and not to work around it. Proceed lets the command go on to Claude Code's usual permission check and run. A dim line in the transcript records each decision, and the status line says when a command is waiting.

Compound commands are understood: cd web && rm -rf build is measured inside web, and so are subshells, bash -c '...', $(...), sudo, env, quoted paths, ~, $HOME, globs and {a,b} / {1..9} braces.

What it holds

CommandMeasured with
rm -r, rm -rf, rm -fr, rm --recursive (any flag order)the files and total size, git status of each
find ... -delete, find ... -exec rm (also -exec sudo rm, -exec sh -c ...)the same find re-run as a listing (-delete becomes -print0); never re-run when it holds -fprint, -fls, another -exec, or a test the system find reads differently (-regex, -readable)
`... \xargs rm`held as "could not measure" (its targets come from the previous command)
git clean -f, -fd, -fdx, -ffdxgit clean -n with the same flags, -f -f included (so nested repositories show), as a dry run
git reset --hard [ref]uncommitted changes discarded, commits dropped from the branch
git checkout -- <paths>, git checkout ., git checkout <file>, git checkout -f, git restore <paths>, git switch --discard-changesuncommitted changes in those paths
git push --force, -f, +refspec, --mirror, even beside --force-with-lease (a lease alone is not held)commits on the remote branch that yours lacks, as of your last fetch
git branch -D, git branch --delete --force (-- before the names too)commits that exist only on that branch
git worktree remove --force / -fheld as "could not measure": it deletes the worktree folder and any uncommitted work in it
> file, 2> file, &> file, `>\file onto an existing file (not >>, not /dev/null`)the file's size and git status

Every one of these is held even when nothing is found to destroy (rm -rf build with no build, git reset --hard on a clean tree): the hold says what it found. Only two cases run untouched, and only on positive proof that they destroy nothing:

  • a redirect (> new.txt) when the whole command is that one simple command (no &&, ||, ;, |, &, newline, subshell, $(...) or unquoted here-doc: an earlier part could create the file first), its target is a plain literal path (no $VAR, ~, glob, braces, .., $(...) or backticks), and the file system answers that it does not exist. Any other answer (a permission error, a link, a folder, a path it cannot read) holds;
  • a bare git checkout <name> (no -f, --force, --, extra paths or other options; <name> a plain token) when git confirms <name> is a local branch, a remote-tracking branch, or the one remote branch it would set up, and no file of that name makes it ambiguous. A git error, a tag or commit, or any doubt holds. (git itself refuses a branch switch that would lose changes.)

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

SettingDefaultWhat it does
alwaysAllowdist,build,.next,coverage,node_modules/.cacheFolders that are fine to delete with rm -r without asking. It never covers find (with -L, -H or -follow it reaches through links), redirects or git. A relative entry matches that run of folder names anywhere beneath the project root (dist matches packages/web/dist, not distro, and never the root itself); an absolute entry matches by prefix. A command passes only when every path it touches is a plain literal path (no glob, braces, $VAR, ~, .. or $(...)) that exists, and both its spelling and where its links lead are inside an allowed folder. Leave it empty to hold everything.

What it can touch

  • Reads: file metadata only (stat, and directory listings to expand globs) for the paths a destructive command names. Never file contents.
  • Runs (by argv, no shell, read-only): the system find (never with -delete, -exec, -ok, -fprint, -fprint0, -fprintf or -fls), du -sk, and git rev-parse, status, ls-files, for-each-ref (to tell a branch name from a path on a bare git checkout <name>), diff-index --numstat, diff-files --numstat, rev-list, log and clean -n (a dry run). Git runs with GIT_OPTIONAL_LOCKS=0 and fsmonitor off, and plain git diff (which rewrites the index) is never used, so git writes nothing. To count changed lines git reads the changed files' contents. While a command is held it keeps one sleep 1 running at a time (PowerShell Start-Sleep on Windows): Claude Code pauses a hook's time limit only while a host call is in flight.
  • Environment: reads HOME.
  • Network: none. Git never fetches, so force-push numbers are as of your last fetch.
  • Model calls: none.
  • Writes: nothing outside its own session state.

Limits

  • A safety net, not a permission system. It reads the command's text, so it does not see:
  • commands inside scripts or piped into a shell (./clean.sh, npm run clean, make clean, echo ... | bash, bash <<EOF, bash script.sh);
  • a program named only at run time ($(echo rm) -rf, "$RM" -rf, eval $CMD), shell and git aliases, flags hidden in variables (rm $FLAGS dir);
  • other interpreters and tools (python -c, node -e, perl -e, rsync --delete, grm, busybox rm, parallel rm, ssh host rm, docker run ... rm);
  • other ways to destroy files or history (mv or cp over a file, sed -i, tee, truncate, dd, git stash drop/clear, git push --delete or origin :branch, git checkout -B, git switch -C, git rm -r, git reset --merge, git gc --prune, database commands).
  • rm without -r (any number of plain files, rm * included) is left alone on purpose, and so are the other one-file-at-a-time tools treated like it: shred, unlink, ln -sf over a file.
  • Only the Bash tool and the Monitor tool's command are held. The Edit and Write tools are not.
  • It measures with the system find; the shell's own find (Claude Code's bundled one, or GNU find) can match a little differently, so a listing is a guide, and an empty or failed one holds as "could not measure".
  • zsh's ** patterns, and brace expressions past 256 words, are held as "could not measure" rather than guessed at.
  • Proceed does not skip Claude Code's own permission prompt: in the default mode you may be asked again.
  • Force-push measurements use your remote-tracking branches, which may be out of date.
  • Sizes are disk usage from du. Up to 8 destructive parts of one command are measured; any past that are held as unmeasured. Each find or du gets 8 seconds.
  • Your home folder, / and top-level system folders are never walked: they are held with a plain warning instead.
  • With nobody to ask (a claude -p run) a destructive command is denied. In a session drawn only on mobile or VS Code where the pane cannot be placed, it is denied too, and the reason says so.
  • The band gives way while a Claude Code survey holds it; the pane does not.
  • macOS and Linux. On Windows, find and du are usually missing, so commands are held as "could not measure".
  • A held command waits as long as you do. Interrupting the turn (Esc at the prompt) stops it.
  • Two paths are verified by reading the code, not by tests (the test kit cannot raise them): closing the pane by hand or Esc counts as Cancel, and interrupting the turn denies the held command.
  • With several holds queued, if you move the focus onto Proceed (Tab) and press Enter, the next hold may draw with the focus still on its Proceed: read each hold before pressing Enter again. Not verified live.

Built from a prompt

This mod was built from prompts/last-look.md.

More mods: the full catalogue.

Source 8 files
hooks/register.tsx 234 lines
1// Last Look: holds a destructive shell command (the Bash tool, and the Monitor
2// tool's command), measures what it would destroy, and waits for the person
3// to press Cancel or Proceed.
4//
5// A safety net, not a permission system: it judges commands by their text,
6// measures with read-only commands, and on any fault of its own it stops the
7// command rather than let it run unchecked.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, RenderSurface } from 'claude-code'
11
12import type { LastLookHold, LastLookReport } from '../types'
13import { classify } from './classify'
14import { MEASURE_ENV, measureAll, shouldHold } from './measure'
15import type { Ctx, Probe } from './measure'
16import { parseAllow } from './paths'
17import { clean } from './text'
18import { drawHold } from './view'
19
20const PANE = 'last-look'
21const holds = atom({ plugin: 'last-look', key: 'holds' } as const, [])
22
23type Choice = 'proceed' | 'cancel'
24type Outcome = Choice | 'aborted' | 'no-wait' | 'nowhere'
25
26/**
27 * The press handlers' way back to the waiting hook. A function cannot live in
28 * $.state, so this one map is module state; a hold it no longer knows (the
29 * module reloaded) is simply cleared when pressed.
30 */
31const waiting = new Map<string, (choice: Choice) => void>()
32
33/**
34 * What the hold keeps in flight while it waits. A hook's 10 s budget counts
35 * only its own running time and stands still while a `$` call is in flight,
36 * but a `$.clock` wait counts; so the hold keeps one cheap, read-only
37 * `sleep` running until the person answers. The second line is for Windows.
38 */
39const SLEEPERS: readonly (readonly string[])[] = [
40  ['sleep', '1'],
41  ['powershell', '-NoProfile', '-Command', 'Start-Sleep -Seconds 1'],
42]
43
44const STATUS = 'holding a command: Cancel or Proceed'
45const LOGGED: Record<Outcome, string> = {
46  proceed: 'you let this run',
47  cancel: 'you cancelled',
48  aborted: 'stopped when the turn was interrupted',
49  'no-wait': 'stopped, it could not wait for your review',
50  nowhere: 'stopped, nowhere to show the review',
51}
52const STOP = 'Do not retry it or reach the same result another way; ask the person what they want instead.'
53const FAILED = 'last-look: its safety check failed, so this command was stopped instead of running unchecked. Ask the person to run it themselves if they intend it.'
54
55export const register: Register = (on, options) => {
56  const allow = parseAllow(String(options.alwaysAllow ?? ''))
57
58  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
59    // Judge first: nothing runs until this hook has decided.
60    const verdict = await guard($, e.command, e.tool_use_id, e.agentId, next.signal, allow)
61    return verdict === 'run' ? next(e) : { deny: verdict.deny }
62  }).catch(($, e, next) => (next.called ? next(e) : { deny: FAILED }))
63
64  // The Monitor tool runs a shell command too.
65  on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
66    if (e.command === undefined) return next(e)
67    const verdict = await guard($, e.command, e.tool_use_id, e.agentId, next.signal, allow)
68    return verdict === 'run' ? next(e) : { deny: verdict.deny }
69  }).catch(($, e, next) => (next.called ? next(e) : { deny: FAILED }))
70
71  // Closing the pane by hand (its close mark, Esc) is a Cancel.
72  on('ui.close', { id: PANE }, async ($, e, next) => {
73    if (e.origin.kind === 'person') for (const resolve of waiting.values()) resolve('cancel')
74    return next(e)
75  }).catch(($, e, next) => next(e))
76
77  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
78    const kit = $.ui.resolve(e)
79    const list = await read($, holds)
80    const first = list[0]
81    if (!first) return <kit.Text dimColor>Nothing is held right now.</kit.Text>
82    return drawHold(kit, first, {
83      rows: e.props.scroll.bodyRows,
84      columns: e.props.bodyColumns,
85      compact: false,
86      surface: e.surface,
87      queued: list.length - 1,
88      onCancel: () => decide($, first.id, 'cancel'),
89      onProceed: () => decide($, first.id, 'proceed'),
90    })
91  })
92
93  // The band above the prompt: where the pane has no room to open. While a
94  // hold waits it draws ALONE: another mod's band holding one control
95  // character would make the engine refuse the whole tree, and the hold with
96  // its Cancel button would vanish. With nothing held, the band is theirs.
97  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
98    if (e.props.hasSurvey) return next(e)
99    const list = await read($, holds)
100    const first = list.find(held => held.mode === 'band')
101    if (!first) return next(e)
102    return drawHold($.ui.resolve(e), first, {
103      rows: e.props.maxRows,
104      columns: e.props.bodyColumns,
105      compact: true,
106      surface: e.surface,
107      queued: list.length - 1,
108      onCancel: () => decide($, first.id, 'cancel'),
109      onProceed: () => decide($, first.id, 'proceed'),
110    })
111  })
112}
113
114/**
115 * Classifies, measures and, when it must, holds one shell command until the
116 * person decides. `run`: let it run; else the refusal the model reads.
117 */
118async function guard($: EngineInterface, command: string, id: string, agentId: string | undefined, signal: AbortSignal, allow: readonly string[]): Promise<'run' | { deny: string }> {
119  const home = await $.env.get('HOME')
120  // Classified once: brace expansion is capped, but never paid for twice.
121  const findings = classify(command, home === undefined ? {} : { home })
122  if (findings.length === 0) return 'run'
123  const ctx = await context($, home, allow)
124
125  $.ui.status(`measuring: ${clip(command, 60)}`)
126  let report: LastLookReport
127  try {
128    report = await measureAll(ctx, command, findings)
129  } finally {
130    $.ui.status(undefined)
131  }
132  if (!shouldHold(report)) return 'run'
133
134  const surfaces = await $.session.surfaces()
135  if (surfaces.length === 0) {
136    return { deny: `last-look: nobody can review this command in this session, so it was stopped (${report.headline}). Ask the person to run it themselves if they intend it.` }
137  }
138  const outcome = await hold($, { id, report, isSubagent: agentId !== undefined }, surfaces, signal)
139  // Claude Code already labels the line with the plugin's name.
140  $.ui.log(`${LOGGED[outcome]}: ${clip(command, 80)}`)
141  return outcome === 'proceed' ? 'run' : { deny: refusal(outcome, report) }
142}
143
144async function context($: EngineInterface, home: string | undefined, allow: readonly string[]): Promise<Ctx> {
145  const [cwd, root, now] = await Promise.all([$.session.cwd(), $.session.root(), $.clock.now()])
146  // Read-only: a command by argv (no shell), a stat, a directory listing.
147  const probe: Probe = {
148    run: (argv, cwd, timeoutMs) => $.process.run(argv, { cwd, timeoutMs, env: MEASURE_ENV }),
149    // Missing is undefined; a stat that fails for another reason (permissions)
150    // rejects, so that part is held unmeasured instead of read as not there.
151    stat: async path => {
152      try {
153        return await $.fs.stat(path, { resolve: true })
154      } catch (error) {
155        if (await $.fs.exists(path)) throw error
156        return undefined
157      }
158    },
159    list: path => $.fs.list(path),
160  }
161  const real = (path: string) => probe.stat(path).then(s => s?.realPath ?? path, () => path)
162  const [cwdReal, rootReal] = await Promise.all([real(cwd), real(root)])
163  return { probe, cwd, cwdReal, root, rootReal, allow, now, ...(home === undefined ? {} : { home }) }
164}
165
166async function hold($: EngineInterface, entry: Omit<LastLookHold, 'mode'>, surfaces: readonly RenderSurface[], signal: AbortSignal): Promise<Outcome> {
167  const decision = new Promise<Choice>(resolve => waiting.set(entry.id, resolve))
168  try {
169    // State first, so the pane's first drawing already has the report.
170    await update($, holds, list => [...list.filter(held => held.id !== entry.id), { ...entry, mode: 'pane' as const }])
171    // No `focus`: the pane never takes the keyboard on its own. With it, a
172    // person typing their next prompt as the hold appears would be typing into
173    // the pane (Tab then Enter could reach Proceed). They click or ctrl+x tab
174    // in, and then Cancel holds the focus, so Enter cancels.
175    const opened = await $.ui
176      .open({ id: PANE, title: 'Last Look', closeOnEscape: true, holdToasts: true, rows: 22, columns: 72 })
177      .catch(() => ({ isPlaced: false as const, reason: 'refused' }))
178    if (!opened.isPlaced) {
179      // A pane opened unasked waits on narrow terminals: use the band.
180      if (!surfaces.some(s => s === 'terminal' || s === 'desktop')) return 'nowhere'
181      await update($, holds, list => list.map(held => (held.id === entry.id ? { ...held, mode: 'band' as const } : held)))
182    }
183    $.ui.status(STATUS)
184    return await waitFor($, decision, signal)
185  } finally {
186    waiting.delete(entry.id)
187    const left = await update($, holds, list => list.filter(held => held.id !== entry.id)).catch(() => [])
188    if (left.length === 0) {
189      $.ui.status(undefined)
190      await $.ui.close({ id: PANE }).catch(() => undefined)
191    }
192  }
193}
194
195async function waitFor($: EngineInterface, decision: Promise<Choice>, signal: AbortSignal): Promise<Outcome> {
196  const aborted = new Promise<'aborted'>(resolve => {
197    if (signal.aborted) resolve('aborted')
198    else signal.addEventListener('abort', () => resolve('aborted'), { once: true })
199  })
200  let sleeper = 0
201  while (true) {
202    const argv = SLEEPERS[sleeper]
203    if (!argv) return 'no-wait'
204    const tick = $.process.run([...argv], { timeoutMs: 10_000 }).then(
205      () => 'tick' as const,
206      () => 'broken' as const,
207    )
208    const first = await Promise.race([decision, aborted, tick])
209    if (first === 'broken') sleeper++
210    else if (first !== 'tick') return first
211  }
212}
213
214async function decide($: EngineInterface, id: string, choice: Choice): Promise<void> {
215  const resolve = waiting.get(id)
216  if (resolve) return resolve(choice)
217  // Nothing waits on it (an earlier load of this module): clear it away.
218  const left = await update($, holds, list => list.filter(held => held.id !== id))
219  if (left.length === 0) await $.ui.close({ id: PANE })
220}
221
222function refusal(outcome: Exclude<Outcome, 'proceed'>, report: LastLookReport): string {
223  if (outcome === 'cancel') return `last-look: the person reviewed this command and chose Cancel, so it did not run (${report.headline}). ${STOP}`
224  if (outcome === 'aborted') return `last-look: this command was held for review when the turn was interrupted, so it did not run. ${STOP}`
225  if (outcome === 'nowhere') return 'last-look: this session has no terminal or desktop view to show the review in, so the command was stopped. Ask the person to run it themselves if they intend it.'
226  return 'last-look: it could not wait for a review here (no sleep command to wait with), so it stopped the command. Ask the person to run it themselves if they intend it.'
227}
228
229/** One clean, short line of untrusted text for the status line and the log. */
230function clip(text: string, max: number): string {
231  const line = clean(text).replace(/\s+/g, ' ').trim()
232  return line.length <= max ? line : `${line.slice(0, max - 3)}...`
233}
234
hooks/classify.ts 724 lines
1// Pure: given a Bash command string, which parts of it can destroy work?
2//
3// No `$`, no I/O: the hook measures what this finds. The rule of thumb is to
4// catch what the spec names in every spelling a model actually writes (flag
5// order, sudo and env prefixes, quoted paths, `cd x && ...`, subshells,
6// `bash -c`, `$(...)`), and to leave look-alikes alone (`echo "rm -rf"`,
7// `grep -r`, a single-file `rm`).
8
9import { literal, parse } from './shell'
10import type { Item, Part, RawWord, Redir, Seq } from './shell'
11
12/** A word as the shell would hand it to the program, when that can be known. */
13export type Word = {
14  text: string
15  /** False when it holds an expansion nobody can know ahead (`$X`, `$(...)`). */
16  known: boolean
17  /** True when unquoted `*`, `?` or `[` make it a pattern the shell expands. */
18  glob: boolean
19  /** As written in the command. */
20  raw: string
21  /**
22   * A literal path as written: no glob, brace, `$VAR`, `~`, `$(...)` or
23   * backticks. Only such paths may be skipped by alwaysAllow, and only they
24   * can be reported "not found" with confidence.
25   */
26  plain: boolean
27}
28
29/** One `cd` on the way to where a command runs. */
30export type CdStep = {
31  dir: Word
32  /**
33   * True when the command runs even if this `cd` fails (`cd x; rm ...`),
34   * so it then runs where the shell already was.
35   */
36  soft: boolean
37}
38
39export type Kind =
40  | 'rm'
41  | 'find-delete'
42  | 'xargs-rm'
43  | 'git-clean'
44  | 'git-reset-hard'
45  | 'git-discard'
46  | 'git-push-force'
47  | 'git-branch-delete'
48  | 'git-worktree-remove'
49  | 'redirect'
50
51export type GitInfo =
52  /** `forces`: 2 for `-ff`, which also removes nested git repositories. */
53  | { sub: 'clean'; flags: string[]; forces: number; excludes: string[]; paths: Word[] }
54  | { sub: 'reset'; ref?: Word }
55  | {
56      sub: 'discard'
57      /** `all`: every uncommitted change; `maybe-ref`: `git checkout x`, a branch or a path. */
58      mode: 'all' | 'paths' | 'maybe-ref'
59      source?: Word
60      /** True when the files come back from the index, so staged work survives. */
61      fromIndex: boolean
62      paths: Word[]
63    }
64  | { sub: 'push'; remote?: Word; refspecs: Word[]; isWholeRepo: boolean }
65  | { sub: 'branch'; names: Word[] }
66  | { sub: 'worktree'; paths: Word[] }
67
68export type Finding = {
69  kind: Kind
70  /** The command's own text, for the person to read. */
71  label: string
72  /** The `cd`s between the session's directory and where it runs. */
73  cd: CdStep[]
74  /** What it acts on: rm operands, find start points, the redirect's file. */
75  targets: Word[]
76  isSudo: boolean
77  git?: GitInfo
78  /** For find: a read-only argv that lists what it would delete. */
79  findListing?: string[]
80  /** Why it cannot be measured, when that is plain from the text alone. */
81  unknown?: string
82  /**
83   * The whole command is this one simple command and this redirect is all it
84   * does that we see: no `&&`, `||`, `;`, `|`, `&`, newline, subshell,
85   * `$(...)` or unquoted here-doc. Only then can "the target does not exist"
86   * be judged before anything runs.
87   */
88  isSole?: boolean
89}
90
91export type ClassifyEnv = { home?: string }
92
93/** How deep `$(...)`, `bash -c` and `eval` may nest before we stop looking. */
94const MAX_DEPTH = 6
95
96export function classify(command: string, env: ClassifyEnv = {}): Finding[] {
97  const out: Finding[] = []
98  const seq = parse(command)
99  walk(seq, command, [], new Map(), env, out, 0)
100  const only = out[0]
101  if (out.length === 1 && only?.kind === 'redirect' && isSoleCommand(seq)) only.isSole = true
102  return out
103}
104
105/** One simple command, nothing before or after it, nothing running inside it. */
106function isSoleCommand(seq: Seq): boolean {
107  const item = seq[0]
108  if (seq.length !== 1 || !item || item.kind !== 'simple' || (item.op !== '' && item.op !== ';')) return false
109  const words = [...item.words, ...item.redirs.flatMap(r => (r.target ? [r.target] : []))]
110  if (words.some(w => w.parts.some(p => p.t === 'sub'))) return false
111  if (item.redirs.some(r => (r.op === '<<' || r.op === '<<-') && !r.isQuotedDoc)) return false
112  return item.redirs.filter(r => r.op === '>' || r.op === '>|' || r.op === '&>' || r.op === '>&').length === 1
113}
114
115type Vars = Map<string, string | undefined>
116
117function walk(seq: Seq, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): CdStep[] {
118  let steps = cd
119  seq.forEach((item, index) => {
120    const before = seq[index - 1]?.op ?? ''
121    // A pipeline part and a `&` command run in a subshell: their `cd` stays there.
122    const isPiped = item.op === '|' || item.op === '|&' || item.op === '&' || before === '|' || before === '|&'
123    if (item.kind === 'group') {
124      const after = walk(item.body, src, steps, vars, env, out, depth)
125      redirects(item.redirs, item, src, steps, vars, env, out, depth)
126      if (!item.subshell && !isPiped) steps = after
127      return
128    }
129    const next = simple(item, src, steps, vars, env, out, depth)
130    if (!isPiped) steps = next
131  })
132  return steps
133}
134
135const RESERVED = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'esac', '!', 'time', '{', '}'])
136const HEADERS = new Set(['for', 'select', 'case', 'function'])
137const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
138
139/** Classifies one simple command; returns the `cd` steps after it. */
140function simple(item: Item & { kind: 'simple' }, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): CdStep[] {
141  const label = src.slice(item.start, item.end).trim()
142  const words = item.words.flatMap(w => resolveAll(w, src, vars, env))
143  // Commands inside the words run first, wherever the shell is now.
144  for (const w of item.words) subs(w, cd, vars, env, out, depth)
145  // Inside `[[ ... ]]` a `>` compares two strings; it writes no file.
146  const isTest = words[0]?.known === true && words[0].text === '[['
147  if (!isTest) redirects(item.redirs, item, src, cd, vars, env, out, depth)
148
149  let argv = words
150  while (argv[0] && RESERVED.has(argv[0].text) && argv[0].known) argv = argv.slice(1)
151  // `function f { body }`: the body is a command like any other.
152  if (argv[0]?.known && argv[0].text === 'function') {
153    argv = argv.slice(2)
154    while (argv[0]?.known && (argv[0].text === '{' || argv[0].text === '()')) argv = argv.slice(1)
155  }
156  const head = argv[0]
157  if (!head || !head.known) return cd
158  if (HEADERS.has(head.text)) return cd
159
160  // Assignments alone set shell variables; before a command they are its env.
161  const firstCommand = argv.findIndex(w => !isAssignment(w))
162  if (firstCommand === -1 || ['export', 'local', 'declare', 'readonly', 'typeset'].includes(head.text)) {
163    for (const w of argv) {
164      if (!isAssignment(w)) continue
165      const name = w.raw.slice(0, w.raw.indexOf('='))
166      vars.set(name, w.known ? w.text.slice(w.text.indexOf('=') + 1) : undefined)
167    }
168    return cd
169  }
170  argv = argv.slice(firstCommand)
171
172  const peeled = peel(argv)
173  argv = peeled.argv
174  const program = argv[0]
175  if (!program || !program.known) return cd
176  const name = base(program.text)
177  const steps = [...cd, ...peeled.cd]
178  const finding = (kind: Kind, rest: Partial<Finding>): void => {
179    out.push({ kind, label, cd: steps, targets: [], isSudo: peeled.isSudo, ...rest })
180  }
181
182  if (name === 'cd' || name === 'pushd') {
183    const operand = argv.slice(1).find(w => !(w.known && /^(-[LPe@]+|--)$/.test(w.text)))
184    const dir = operand ?? { text: env.home ?? '', known: env.home !== undefined, glob: false, raw: '~', plain: false }
185    if (dir.known && dir.text === '-') return [...cd, { dir: { ...dir, known: false }, soft: false }]
186    return [...cd, { dir, soft: item.op !== '&&' }]
187  }
188  if (name === 'popd') return [...cd, { dir: { text: '', known: false, glob: false, raw: 'popd', plain: false }, soft: false }]
189
190  if (peeled.viaXargs && name === 'rm') {
191    finding('xargs-rm', { unknown: 'it deletes whatever the command before it prints' })
192    return cd
193  }
194  if (SHELLS.has(name) && depth < MAX_DEPTH) {
195    const script = shellScript(argv)
196    if (script?.known) walk(parse(script.text), script.text, steps, new Map(vars), env, out, depth + 1)
197    return cd
198  }
199  if (name === 'eval' && depth < MAX_DEPTH) {
200    const text = argv.slice(1).map(w => w.text).join(' ')
201    if (argv.slice(1).every(w => w.known)) walk(parse(text), text, steps, new Map(vars), env, out, depth + 1)
202    return cd
203  }
204  if (name === 'rm') {
205    const rm = parseRm(argv)
206    if (rm.isRecursive && rm.operands.length > 0) finding('rm', { targets: rm.operands })
207  } else if (name === 'find') {
208    const found = parseFind(argv)
209    if (found) finding('find-delete', found)
210  } else if (name === 'git') {
211    const git = parseGit(argv, peeled.viaXargs)
212    if (git) {
213      const unknown = peeled.viaXargs ? 'more arguments come from the command before it' : git.finding.unknown
214      out.push({ label, cd: [...steps, ...git.cd], targets: [], isSudo: peeled.isSudo, ...git.finding, ...(unknown ? { unknown } : {}) })
215    }
216  }
217  return cd
218}
219
220function redirects(redirs: Redir[], item: Item, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): void {
221  for (const r of redirs) {
222    if (!r.target || r.target.parts.length === 0) continue
223    subs(r.target, cd, vars, env, out, depth)
224    const overwrites = r.op === '>' || r.op === '>|' || r.op === '&>' || r.op === '>&'
225    if (!overwrites) continue
226    const target = resolve(r.target, src, vars, env)
227    if (target.known && (target.text.startsWith('/dev/') || target.text === '')) continue
228    out.push({
229      kind: 'redirect',
230      label: src.slice(item.start, item.end).trim(),
231      cd,
232      targets: [target],
233      isSudo: false,
234      ...(target.known ? {} : { unknown: `it writes to ${target.raw}, which is only known when it runs` }),
235    })
236  }
237}
238
239/** Walks the commands a word runs inside it (`$(...)`, backticks). */
240function subs(w: RawWord, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): void {
241  if (depth >= MAX_DEPTH) return
242  for (const p of w.parts) {
243    if (p.t === 'sub') walk(parse(p.src), p.src, cd, new Map(vars), env, out, depth + 1)
244  }
245}
246
247// ---------------------------------------------------------------- words
248
249function resolve(w: RawWord, src: string, vars: Vars, env: ClassifyEnv): Word {
250  let text = ''
251  let known = true
252  let glob = false
253  let plain = true
254  w.parts.forEach((p: Part, index) => {
255    if (p.t === 'lit') {
256      let s = p.s
257      if (index === 0 && !p.quoted && s.startsWith('~')) {
258        plain = false
259        const user = /^~([^/]*)/.exec(s)?.[1] ?? ''
260        if (user === '' && env.home !== undefined) s = env.home + s.slice(1)
261        else known = false
262      }
263      if (!p.quoted && /[*?[]/.test(s)) glob = true
264      // An unquoted brace may expand (or be refused) by the shell: not a plain path.
265      if (!p.quoted && /[{}]/.test(s)) plain = false
266      text += s
267    } else if (p.t === 'var') {
268      plain = false
269      const value = vars.has(p.name) ? vars.get(p.name) : p.name === 'HOME' ? env.home : undefined
270      // Unquoted, bash splits the value on spaces and globs it: one word no more.
271      if (value === undefined || (!p.quoted && /[\s*?[]/.test(value))) known = false
272      else text += value
273    } else {
274      plain = false
275      known = false
276    }
277  })
278  return { text, known, glob, raw: src.slice(w.start, w.end), plain: plain && !glob }
279}
280
281export function literalWord(text: string): Word {
282  return { text, known: true, glob: false, raw: text, plain: true }
283}
284
285/** Resolves a word, expanding braces (`{a,b}`, `{1..3}`) the way the shell would first. */
286function resolveAll(w: RawWord, src: string, vars: Vars, env: ClassifyEnv): Word[] {
287  const only = w.parts.length === 1 ? w.parts[0] : undefined
288  // Bash does not brace-expand an assignment (`X={a,b}`), so neither do we.
289  const isBraced = only?.t === 'lit' && !only.quoted && /\{[^{}]*(,|\.\.)[^{}]*\}/.test(only.s) && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(only.s)
290  if (!isBraced || only?.t !== 'lit') return [resolve(w, src, vars, env)]
291  const expanded = braces(only.s)
292  const raw = src.slice(w.start, w.end)
293  // Past the cap the word is unknown, so it is held, never cut short.
294  if (!expanded) return [{ text: only.s, known: false, glob: false, raw, plain: false }]
295  return expanded.map(s => ({ ...resolve({ ...w, parts: [{ t: 'lit', s, quoted: false }] }, src, vars, env), plain: false }))
296}
297
298/** Most words one brace expression may become, and most nested groups, before it counts as unknown. */
299const BRACE_CAP = 256
300const BRACE_GROUPS = 8
301
302/**
303 * Brace expansion with bounded work: comma lists and `{1..9}` / `{a..e}`
304 * ranges, generated one at a time; undefined once past the cap.
305 */
306export function braces(s: string): string[] | undefined {
307  const out: string[] = []
308  const expand = (text: string, depth: number): boolean => {
309    if (depth > BRACE_GROUPS) return false
310    const group = braceGroup(text)
311    if (!group) {
312      out.push(text)
313      return out.length <= BRACE_CAP
314    }
315    if (group.alternatives === undefined) return false
316    for (const alternative of group.alternatives) {
317      if (!expand(group.head + alternative + group.tail, depth + 1)) return false
318    }
319    return true
320  }
321  return expand(s, 0) ? out : undefined
322}
323
324/** The first `{...}` that expands: its alternatives (undefined: too many), and what is around it. */
325function braceGroup(s: string): { head: string; tail: string; alternatives: string[] | undefined } | undefined {
326  for (let open = s.indexOf('{'); open !== -1; open = s.indexOf('{', open + 1)) {
327    let depth = 0
328    const commas: number[] = []
329    for (let j = open; j < s.length; j++) {
330      if (s[j] === '{') depth++
331      else if (s[j] === '}') depth--
332      else if (s[j] === ',' && depth === 1) commas.push(j)
333      if (depth !== 0) continue
334      const head = s.slice(0, open)
335      const tail = s.slice(j + 1)
336      const body = s.slice(open + 1, j)
337      if (commas.length > 0) {
338        const cuts = [open, ...commas, j]
339        return { head, tail, alternatives: cuts.slice(1).map((cut, k) => s.slice((cuts[k] ?? open) + 1, cut)) }
340      }
341      const range = /^(-?\d+|[a-zA-Z])\.\.(-?\d+|[a-zA-Z])$/.exec(body)
342      if (range) return { head, tail, alternatives: sequence(range[1] ?? '', range[2] ?? '') }
343      break
344    }
345  }
346  return undefined
347}
348
349function sequence(from: string, to: string): string[] | undefined {
350  const isNumber = /\d/.test(from)
351  if (isNumber !== /\d/.test(to)) return undefined
352  const a = isNumber ? Number(from) : from.charCodeAt(0)
353  const b = isNumber ? Number(to) : to.charCodeAt(0)
354  if (Math.abs(b - a) >= BRACE_CAP) return undefined
355  const step = a <= b ? 1 : -1
356  const out: string[] = []
357  for (let n = a; step > 0 ? n <= b : n >= b; n += step) out.push(isNumber ? String(n) : String.fromCharCode(n))
358  return out
359}
360
361function isAssignment(w: Word): boolean {
362  return /^[A-Za-z_][A-Za-z0-9_]*=/.test(w.raw)
363}
364
365export function base(path: string): string {
366  return path.slice(path.lastIndexOf('/') + 1)
367}
368
369// ---------------------------------------------------------------- prefixes
370
371type Peeled = { argv: Word[]; cd: CdStep[]; isSudo: boolean; viaXargs: boolean }
372
373/** Options of each prefix command that take a value of their own. */
374const VALUED: Record<string, readonly string[]> = {
375  sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U', '--user', '--group', '--chdir'],
376  doas: ['-u', '-C'],
377  env: ['-u', '-C', '-S', '-P', '--unset', '--chdir', '--split-string'],
378  nice: ['-n', '--adjustment'],
379  ionice: ['-c', '-n', '-p', '-P', '-u', '--class', '--classdata'],
380  stdbuf: ['-i', '-o', '-e', '--input', '--output', '--error'],
381  timeout: ['-s', '-k', '--signal', '--kill-after'],
382  exec: ['-a'],
383  xargs: ['-E', '-I', '-L', '-n', '-P', '-s', '-d', '-a', '--arg-file', '--delimiter', '--max-args', '--max-procs', '--max-chars', '--max-lines', '--replace', '--eof'],
384}
385const PREFIXES = new Set([...Object.keys(VALUED), 'nohup', 'time', 'command', 'builtin', 'chronic', 'caffeinate', 'unbuffer'])
386
387/** Strips `sudo`, `env X=1`, `nohup`, `xargs` and kin down to the real program. */
388function peel(words: Word[]): Peeled {
389  let argv = words
390  const cd: CdStep[] = []
391  let isSudo = false
392  let viaXargs = false
393  for (let guard = 0; guard < 8; guard++) {
394    const head = argv[0]
395    if (!head?.known) break
396    const name = base(head.text)
397    if (!PREFIXES.has(name)) break
398    if (name === 'command' && argv.slice(1).some(w => w.text === '-v' || w.text === '-V')) return { argv: [], cd, isSudo, viaXargs }
399    if (name === 'sudo' || name === 'doas') isSudo = true
400    if (name === 'xargs') viaXargs = true
401    const valued = VALUED[name] ?? []
402    let k = 1
403    while (k < argv.length) {
404      const w = argv[k]
405      if (!w?.known) break
406      if (w.text === '--') {
407        k++
408        break
409      }
410      if (name === 'env' && isAssignment(w)) {
411        k++
412        continue
413      }
414      if (!w.text.startsWith('-') || w.text === '-') break
415      const flag = w.text.split('=')[0] ?? ''
416      const takesValue = valued.includes(flag) && !w.text.includes('=')
417      const value = takesValue ? argv[k + 1] : w.text.includes('=') ? { ...w, text: w.text.slice(w.text.indexOf('=') + 1) } : undefined
418      if ((name === 'env' && (flag === '-C' || flag === '--chdir')) || (name === 'sudo' && (flag === '-D' || flag === '--chdir'))) {
419        if (value) cd.push({ dir: value, soft: false })
420      }
421      k += takesValue ? 2 : 1
422    }
423    // `timeout 10 cmd`: the duration comes before the command.
424    if (name === 'timeout' && argv[k]?.known && /^[\d.]+[smhd]?$/.test(argv[k]?.text ?? '')) k++
425    argv = argv.slice(k)
426    // `sudo FOO=1 rm ...`: variables set for the command, not the command.
427    while (argv[0] && isAssignment(argv[0])) argv = argv.slice(1)
428  }
429  return { argv, cd, isSudo, viaXargs }
430}
431
432/** `bash -c 'script'`: the script, or undefined when there is no `-c`. */
433function shellScript(argv: Word[]): Word | undefined {
434  let hasC = false
435  for (let k = 1; k < argv.length; k++) {
436    const w = argv[k]
437    if (!w) break
438    if (w.known && /^-[a-zA-Z]+$/.test(w.text)) {
439      if (w.text.includes('c')) hasC = true
440      if (w.text === '-o' || w.text === '-O') k++
441      continue
442    }
443    if (w.known && w.text.startsWith('--')) continue
444    return hasC ? w : undefined
445  }
446  return undefined
447}
448
449// ---------------------------------------------------------------- rm
450
451export function parseRm(argv: Word[]): { isRecursive: boolean; operands: Word[] } {
452  let isRecursive = false
453  let isEnd = false
454  const operands: Word[] = []
455  for (const w of argv.slice(1)) {
456    const t = w.text
457    if (!isEnd && w.known && t === '--') isEnd = true
458    else if (!isEnd && w.known && t.startsWith('--')) {
459      // GNU takes any unique prefix of a long option: --rec, --recur.
460      if (t.length >= 3 && '--recursive'.startsWith(t)) isRecursive = true
461    } else if (!isEnd && w.known && t.startsWith('-') && t.length > 1) {
462      if (/[rR]/.test(t)) isRecursive = true
463    } else operands.push(w)
464  }
465  return { isRecursive, operands }
466}
467
468// ---------------------------------------------------------------- find
469
470const FIND_LEADING = new Set(['-H', '-L', '-P', '-E', '-X', '-s', '-x', '-d'])
471const FIND_VALUED = new Set([
472  '-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-lname', '-ilname',
473  '-type', '-xtype', '-size', '-perm', '-user', '-group', '-uid', '-gid', '-maxdepth', '-mindepth', '-mtime',
474  '-atime', '-ctime', '-mmin', '-amin', '-cmin', '-newer', '-anewer', '-cnewer', '-samefile', '-inum', '-links',
475  '-fstype', '-used', '-context', '-printf', '-fprint', '-fprint0', '-fls', '-regextype', '-newermt', '-newerct',
476  '-newerat', '-newerBt',
477])
478const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
479/**
480 * Predicates that write files or run commands. Never copied into a measuring
481 * command: a find holding one is held unmeasured (GNU find opens -fprint
482 * files for writing while it parses, before it runs anything).
483 */
484export const FIND_WRITES = new Set(['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls'])
485/** Tests whose meaning differs between the system find and the shell's own (bfs, GNU). */
486const FIND_UNPORTABLE = new Set(['-regex', '-iregex', '-regextype', '-readable', '-writable', '-executable'])
487/** Actions that write or print on their own: a listing re-run would not be clean. */
488const FIND_SIDE_EFFECTS = new Set(['-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls', '-delete', '-print', '-print0', '-printf', '-ls'])
489
490export function parseFind(argv: Word[]): Partial<Finding> | undefined {
491  const args = argv.slice(1)
492  let k = 0
493  const leading: string[] = []
494  while (k < args.length) {
495    const t = args[k]?.text ?? ''
496    if (FIND_LEADING.has(t) || /^-O\d$/.test(t)) {
497      leading.push(t)
498      k++
499    } else if (t === '-D' || t === '-f') {
500      leading.push(t, args[k + 1]?.text ?? '')
501      k += 2
502    } else break
503  }
504  const starts: Word[] = []
505  while (k < args.length) {
506    const t = args[k]?.text ?? ''
507    if (args[k]?.known && (t.startsWith('-') || t === '(' || t === '!' || t === ')' || t === ',')) break
508    const w = args[k]
509    if (w) starts.push(w)
510    k++
511  }
512  const expr = args.slice(k)
513
514  // Scan the expression, skipping the values of tests that take one.
515  let deletes = false
516  let usesDelete = false
517  const listing: string[] = []
518  const isKnown = expr.every(w => w.known) && starts.every(w => w.known && !w.glob)
519  let isListable = true
520  let isPortable = true
521  for (let j = 0; j < expr.length; j++) {
522    const t = expr[j]?.text ?? ''
523    // Writers first: -fprint takes a value, but must never reach the listing.
524    if (t === '-fprint' || t === '-fprint0' || t === '-fprintf' || t === '-fls') {
525      isListable = false
526      j += t === '-fprintf' ? 2 : 1
527      continue
528    }
529    if (FIND_UNPORTABLE.has(t)) isPortable = false
530    if (FIND_VALUED.has(t)) {
531      listing.push(t, expr[j + 1]?.text ?? '')
532      j++
533      continue
534    }
535    if (t === '-delete') {
536      deletes = true
537      usesDelete = true
538      listing.push('-print0')
539      continue
540    }
541    if (FIND_EXEC.has(t)) {
542      let end = j + 1
543      while (end < expr.length && expr[end]?.text !== ';' && expr[end]?.text !== '+') end++
544      // `-exec sudo rm`, `-exec env rm`: peel the same prefixes as a command line.
545      const command = peel(expr.slice(j + 1, end)).argv
546      const program = command[0]?.known ? base(command[0].text) : ''
547      if (program === 'rm') {
548        deletes = true
549        listing.push('-print0')
550      } else if (SHELLS.has(program) || program === 'eval' || program === 'xargs' || !command[0]?.known) {
551        // A shell or a run-time program could delete anything: held unmeasured.
552        deletes = true
553        isListable = false
554      } else {
555        isListable = false
556      }
557      j = end
558      continue
559    }
560    if (FIND_SIDE_EFFECTS.has(t)) isListable = false
561    listing.push(t)
562  }
563  if (!deletes) return undefined
564  const from = starts.length > 0 ? starts : [{ text: '.', known: true, glob: false, raw: '.', plain: true }]
565  if (!isKnown) return { targets: from, unknown: 'it names paths that are only known when it runs' }
566  if (!isListable) return { targets: from, unknown: 'its find expression does more than delete, so it was not re-run to look' }
567  if (!isPortable) return { targets: from, unknown: 'it uses a find test the system find reads differently from the shell' }
568  // -delete turns on -depth, which also switches -prune off: list the same way.
569  return { targets: from, findListing: [...leading, ...from.map(w => w.text), ...(usesDelete ? ['-depth'] : []), ...listing] }
570}
571
572// ---------------------------------------------------------------- git
573
574/** Options of a branch switch that cannot discard anything. */
575const SWITCH_FLAGS = new Set(['-q', '--quiet', '-t', '--track', '--no-track', '--detach', '--progress', '--no-progress', '--guess', '--no-guess'])
576
577type GitParsed = { cd: CdStep[]; finding: Pick<Finding, 'kind' | 'git' | 'unknown'> }
578
579function shortFlags(t: string): string {
580  return /^-[A-Za-z]+$/.test(t) ? t.slice(1) : ''
581}
582
583export function parseGit(argv: Word[], viaXargs = false): GitParsed | undefined {
584  const cd: CdStep[] = []
585  let k = 1
586  let unknown: string | undefined
587  while (k < argv.length) {
588    const w = argv[k]
589    if (!w?.known || !w.text.startsWith('-')) break
590    if (w.text === '-C') {
591      const dir = argv[k + 1]
592      if (dir) cd.push({ dir, soft: false })
593      k += 2
594    } else if (w.text === '-c' || w.text === '--git-dir' || w.text === '--work-tree' || w.text === '--namespace') {
595      if (w.text !== '-c') unknown = 'it points git at another repository'
596      k += 2
597    } else {
598      if (w.text.startsWith('--git-dir=') || w.text.startsWith('--work-tree=')) unknown = 'it points git at another repository'
599      k++
600    }
601  }
602  const sub = argv[k]
603  if (!sub?.known) return undefined
604  const rest = argv.slice(k + 1)
605  const parsed = gitSub(sub.text, rest, viaXargs)
606  if (!parsed) return undefined
607  return { cd, finding: unknown ? { ...parsed, unknown } : parsed }
608}
609
610function gitSub(sub: string, rest: Word[], viaXargs: boolean): Pick<Finding, 'kind' | 'git'> | undefined {
611  const positionals: Word[] = []
612  const after: Word[] = []
613  let isEnd = false
614  const flags: string[] = []
615  for (let j = 0; j < rest.length; j++) {
616    const w = rest[j]
617    if (!w) continue
618    if (isEnd) after.push(w)
619    else if (w.known && w.text === '--') isEnd = true
620    else if (w.known && w.text.startsWith('-') && w.text !== '-') {
621      flags.push(w.text)
622      // Options whose value is a separate word.
623      const valued: Record<string, readonly string[]> = {
624        clean: ['-e', '--exclude'],
625        checkout: ['-b', '-B', '-c', '-C', '--orphan', '--conflict'],
626        restore: ['-s', '--source'],
627        push: ['-o', '--push-option', '--repo', '--receive-pack', '--exec'],
628        branch: ['-u', '--set-upstream-to', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--format', '--sort'],
629      }
630      if ((valued[sub] ?? []).includes(w.text)) {
631        const value = rest[j + 1]
632        if (value) flags.push(value.text)
633        j++
634      }
635    } else positionals.push(w)
636  }
637  const has = (...names: string[]) => flags.some(f => names.includes(f))
638  const hasShort = (ch: string) => flags.some(f => shortFlags(f).includes(ch))
639
640  if (sub === 'clean') {
641    const forces = flags.reduce((n, f) => n + (f === '--force' ? 1 : (shortFlags(f).match(/f/g) ?? []).length), 0)
642    if (forces === 0 || hasShort('n') || has('--dry-run')) return undefined
643    const keep = ['d', 'x', 'X'].filter(hasShort)
644    const excludes: string[] = []
645    flags.forEach((f, j) => {
646      if (f === '-e' || f === '--exclude') excludes.push(flags[j + 1] ?? '')
647      else if (f.startsWith('--exclude=')) excludes.push(f.slice('--exclude='.length))
648    })
649    return { kind: 'git-clean', git: { sub: 'clean', flags: keep, forces, excludes, paths: [...positionals, ...after] } }
650  }
651  if (sub === 'worktree') {
652    // `git worktree remove --force` deletes the worktree folder, uncommitted work and all.
653    const [action, ...targets] = [...positionals, ...after]
654    if (action?.text !== 'remove' || !(has('--force') || hasShort('f'))) return undefined
655    return { kind: 'git-worktree-remove', git: { sub: 'worktree', paths: targets } }
656  }
657  if (sub === 'reset') {
658    if (!has('--hard')) return undefined
659    const ref = positionals[0]
660    return { kind: 'git-reset-hard', git: ref ? { sub: 'reset', ref } : { sub: 'reset' } }
661  }
662  if (sub === 'checkout') {
663    const isForced = has('-f', '--force')
664    if (has('-p', '--patch')) return undefined
665    if (!isForced && has('-b', '-B', '-c', '-C', '--orphan')) return undefined
666    const discard = (mode: 'all' | 'paths' | 'maybe-ref', paths: Word[], source?: Word): Pick<Finding, 'kind' | 'git'> => ({
667      kind: 'git-discard',
668      git: { sub: 'discard', mode, paths, fromIndex: source === undefined, ...(source ? { source } : {}) },
669    })
670    if (isEnd) {
671      if (after.length > 0) return discard('paths', after, positionals[0])
672      return isForced ? discard('all', []) : undefined
673    }
674    if (isForced) return discard('all', [])
675    const [first, ...others] = positionals
676    if (!first || (first.known && first.text === '-')) return undefined
677    if (others.length > 0) return discard('paths', others, first)
678    // Only a bare `git checkout <plain name>` may turn out to be a branch switch
679    // (the measurer asks git). Any other option, or a name that is not a plain
680    // token, is taken as paths and held.
681    const isBare = flags.every(f => SWITCH_FLAGS.has(f.split('=')[0] ?? ''))
682    const isPath = !isBare || !first.plain || first.text === '.' || first.text.startsWith('./') || first.text.startsWith(':/')
683    return discard(isPath ? 'paths' : 'maybe-ref', [first])
684  }
685  if (sub === 'restore') {
686    const staged = hasShort('S') || has('--staged')
687    const worktree = hasShort('W') || has('--worktree')
688    if ((staged && !worktree) || hasShort('p') || has('--patch')) return undefined
689    const paths = [...positionals, ...after]
690    if (paths.length === 0) return undefined
691    const sourceAt = flags.findIndex(f => f === '-s' || f === '--source')
692    const sourceText = sourceAt >= 0 ? flags[sourceAt + 1] : flags.find(f => f.startsWith('--source='))?.slice('--source='.length)
693    const source = sourceText !== undefined ? literalWord(sourceText) : staged ? literalWord('HEAD') : undefined
694    return { kind: 'git-discard', git: { sub: 'discard', mode: 'paths', paths, fromIndex: source === undefined, ...(source ? { source } : {}) } }
695  }
696  if (sub === 'switch') {
697    if (!has('--discard-changes', '-f', '--force')) return undefined
698    return { kind: 'git-discard', git: { sub: 'discard', mode: 'all', paths: [], fromIndex: false } }
699  }
700  if (sub === 'push') {
701    const name = (f: string) => f.split('=')[0] ?? ''
702    // git takes a unique prefix of a long option: --force-with is the lease.
703    const isMirror = flags.some(f => name(f) === '--mirror')
704    const isForced = has('--force') || hasShort('f') || isMirror || [...positionals, ...after].slice(1).some(w => w.text.startsWith('+'))
705    // A lease guards only a push that is not also forced: `--force` (or -f,
706    // a `+refspec`, --mirror) switches the lease off, so it is held.
707    if (!isForced || has('--dry-run') || hasShort('n')) return undefined
708    const isWholeRepo = isMirror || has('--all', '--branches', '--tags')
709    const [remote, ...refspecs] = [...positionals, ...after]
710    return { kind: 'git-push-force', git: { sub: 'push', refspecs, isWholeRepo, ...(remote ? { remote } : {}) } }
711  }
712  if (sub === 'branch') {
713    const isDelete = hasShort('d') || hasShort('D') || has('--delete')
714    const isForced = hasShort('D') || hasShort('f') || has('--force')
715    if (!isDelete || !isForced || hasShort('r') || has('--remotes')) return undefined
716    // Names after `--` are names too: `git branch -D -- feature`.
717    const names = [...positionals, ...after]
718    return names.length > 0 || viaXargs ? { kind: 'git-branch-delete', git: { sub: 'branch', names } } : undefined
719  }
720  return undefined
721}
722
723export { literal }
724
hooks/measure.ts 908 lines
1// Measures what a held command would destroy, to show the person. It never
2// decides WHETHER to hold: once the classifier calls a command destructive it
3// is held, and an empty or failed measurement only means the hold says
4// "could not measure: review it yourself". The two exceptions, both
5// classification the text alone cannot finish, are marked `isHarmless`: a
6// redirect to a plain path that does not exist yet, and `git checkout <name>`
7// that git confirms is a branch. alwaysAllow (plain literal paths only) is
8// the one skip the person configures.
9//
10// Read-only commands only: `find` without -delete (no write or exec predicate
11// is ever copied), `du`, and git's rev-parse, for-each-ref, status, ls-files,
12// diff-index, diff-files, rev-list, log and clean -n (a dry run), all with optional locks
13// off, so git writes nothing either.
14
15import type { FsEntry, FsStat, ProcessRunResult } from 'claude-code'
16
17import type { LastLookItem, LastLookReport, LastLookRow, LastLookTone } from '../types'
18import { FIND_WRITES } from './classify'
19import type { CdStep, Finding, Word } from './classify'
20import { globSegment, hasGlob, isAbsolute, isAllowed, join, rebase, show, within } from './paths'
21
22/**
23 * The three read-only things measuring needs from the host. The hooks module
24 * builds it from `$` (which never crosses an import); tests can fake it.
25 */
26export type Probe = {
27  run: (argv: readonly string[], cwd: string, timeoutMs: number) => Promise<ProcessRunResult>
28  /** A path's stat with `realPath`; undefined when it does not exist, a rejection for any other failure. */
29  stat: (path: string) => Promise<FsStat | undefined>
30  list: (path: string) => Promise<FsEntry[]>
31}
32
33export type Ctx = {
34  probe: Probe
35  /** The session's working directory, and where its links lead. */
36  cwd: string
37  cwdReal: string
38  /** The project root: alwaysAllow folders are looked for beneath it. */
39  root: string
40  rootReal: string
41  home?: string
42  allow: readonly string[]
43  now: number
44}
45
46export const COULD_NOT = 'could not measure: review it yourself'
47
48/** Findings measured per command; any past it are held unmeasured. */
49const MAX_ITEMS = 8
50/** Detail rows kept per item: more than any pane shows. */
51const MAX_ROWS = 40
52/** Paths handed to one du call, and kept from a listing. */
53const MAX_ARGS = 2000
54const LIST_MS = 8_000
55const GIT_MS = 6_000
56
57const GIT = ['git', '-c', 'core.quotePath=false', '-c', 'core.fsmonitor=false']
58
59/** Set on every measuring command: plain English output, no git locks, no prompts. */
60export const MEASURE_ENV = { LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
61
62export async function measureAll(ctx: Ctx, command: string, findings: readonly Finding[]): Promise<LastLookReport> {
63  const items: LastLookItem[] = []
64  for (const finding of findings.slice(0, MAX_ITEMS)) {
65    try {
66      items.push(await measureOne(ctx, finding))
67    } catch (error) {
68      items.push(unmeasured(finding, undefined, `measuring failed: ${short(error)}`))
69    }
70  }
71  const extra = findings.length - MAX_ITEMS
72  if (extra > 0) {
73    items.push({ ...blank(`${extra} more destructive ${extra === 1 ? 'step' : 'steps'}`), effect: 'unknown', summary: COULD_NOT, summaryTone: 'warning' })
74  }
75  return { command: clip(command, 2000), items, ...headline(items) }
76}
77
78/**
79 * Every destructive command holds. Only alwaysAllow, or a part that turned out
80 * not to be destructive at all (`isHarmless`), lets it through; what the
81 * measurement found or failed to find never does.
82 */
83export function shouldHold(report: LastLookReport): boolean {
84  return report.items.some(item => !item.isAllowed && !item.isHarmless)
85}
86
87async function measureOne(ctx: Ctx, f: Finding): Promise<LastLookItem> {
88  const at = await whereItRuns(ctx, f.cd)
89  if ('unknown' in at) return unmeasured(f, undefined, at.unknown)
90  const where = show(at.dir, ctx.cwd, ctx.home)
91  const notes: LastLookRow[] = at.notes.map(text => ({ text, tone: 'warning' }))
92  if (f.isSudo) notes.push({ text: 'It runs with sudo, as root.', tone: 'warning' })
93  if (f.unknown) return unmeasured(f, where, f.unknown, notes)
94  const git = f.git
95  switch (f.kind) {
96    case 'rm': {
97      const paths = await expand(ctx, at.dir, f.targets)
98      return typeof paths === 'string' ? unmeasured(f, where, paths, notes) : remove(ctx, f, where, notes, at.dir, paths, 'words')
99    }
100    case 'find-delete':
101      return findDelete(ctx, f, where, notes, at.dir)
102    case 'redirect':
103      return overwrite(ctx, f, where, notes, at.dir)
104    case 'git-clean':
105      return git?.sub === 'clean' ? clean(ctx, f, where, notes, at.dir, git) : unmeasured(f, where, 'unexpected git form', notes)
106    case 'git-reset-hard':
107    case 'git-discard':
108      return discard(ctx, f, where, notes, at.dir)
109    case 'git-push-force':
110      return push(ctx, f, where, notes, at.dir)
111    case 'git-branch-delete':
112      return branchDelete(ctx, f, where, notes, at.dir)
113    case 'git-worktree-remove': {
114      const paths = git?.sub === 'worktree' ? git.paths.map(w => w.raw).join(' ') : ''
115      return unmeasured(f, where, `it deletes the worktree ${paths} and any uncommitted work in it`, notes)
116    }
117    case 'xargs-rm':
118      return unmeasured(f, where, 'it deletes whatever the command before it prints', notes)
119  }
120}
121
122// ---------------------------------------------------------------- where, what
123
124async function whereItRuns(ctx: Ctx, steps: readonly CdStep[]): Promise<{ dir: string; notes: string[] } | { unknown: string }> {
125  let dir = ctx.cwd
126  const notes: string[] = []
127  for (const step of steps) {
128    if (!step.dir.known) return { unknown: `it runs after cd ${step.dir.raw}, which is only known when it runs` }
129    const next = join(dir, step.dir.text)
130    if (step.soft && (await stat(ctx, next))?.kind !== 'dir') {
131      notes.push(`cd ${step.dir.raw} would fail, so this would run in ${show(dir, ctx.cwd, ctx.home)}`)
132      continue
133    }
134    dir = next
135  }
136  return { dir, notes }
137}
138
139/** A literal path as written, with no `..` (the kernel resolves `..` after links, not by spelling). */
140function isPlainPath(w: Word): boolean {
141  return w.plain && !w.text.split('/').includes('..')
142}
143
144function plainSteps(steps: readonly CdStep[]): boolean {
145  return steps.every(step => isPlainPath(step.dir))
146}
147
148type Path = { abs: string; slash: boolean; plain: boolean }
149
150/** Words to absolute paths, expanding globs as the shell would where it can. */
151async function expand(ctx: Ctx, dir: string, words: readonly Word[]): Promise<Path[] | string> {
152  const out: Path[] = []
153  for (const w of words) {
154    if (!w.known) return `it acts on ${w.raw}, which is only known when it runs`
155    const slash = w.text.endsWith('/')
156    if (!w.glob) {
157      out.push({ abs: join(dir, w.text), slash, plain: isPlainPath(w) })
158      continue
159    }
160    // zsh and bash differ on `**`; no single answer to measure against.
161    if (w.text.includes('**')) return `it uses a recursive ** pattern (${w.raw}), which shells expand differently`
162    const matches = await glob(ctx, dir, w.text)
163    out.push(...(matches.length > 0 ? matches.map(abs => ({ abs, slash, plain: false })) : [{ abs: join(dir, w.text), slash, plain: false }]))
164  }
165  return out
166}
167
168async function glob(ctx: Ctx, dir: string, pattern: string): Promise<string[]> {
169  let current = [isAbsolute(pattern) ? '/' : dir]
170  for (const segment of pattern.split('/')) {
171    if (segment === '' || segment === '.') continue
172    if (!hasGlob(segment)) {
173      current = current.map(path => join(path, segment))
174      continue
175    }
176    const wanted = globSegment(segment)
177    const next: string[] = []
178    for (const path of current) {
179      // A folder that is not there matches nothing; one that will not list is not "no match".
180      const entries = await ctx.probe.list(path).catch(async error => {
181        if (await stat(ctx, path)) throw error
182        return []
183      })
184      for (const entry of entries) {
185        const isHidden = entry.name.startsWith('.') && !segment.startsWith('.')
186        if (!isHidden && wanted.test(entry.name)) next.push(join(path, entry.name))
187      }
188      if (next.length >= MAX_ARGS) break
189    }
190    current = next.slice(0, MAX_ARGS)
191    if (current.length === 0) return []
192  }
193  return current
194}
195
196type Target = Path & { real: string; stat: FsStat }
197
198async function existing(ctx: Ctx, paths: readonly Path[]): Promise<Target[]> {
199  const stats = await Promise.all(paths.map(path => stat(ctx, path.abs)))
200  return paths.flatMap((path, k) => {
201    const found = stats[k]
202    return found ? [{ ...path, real: found.realPath ?? path.abs, stat: found }] : []
203  })
204}
205
206/** `/`, your home folder or one above it, or a top-level system folder. */
207function isCatastrophic(path: string, home?: string): boolean {
208  if (path === '/') return true
209  if (home !== undefined && within(path, home)) return true
210  return path.split('/').filter(Boolean).length === 1
211}
212
213/**
214 * alwaysAllow: every target a plain literal path that exists, reached through
215 * plain `cd`s, and both its spelling and its real path inside an allowed
216 * folder. A real path another hook withheld is never allowed.
217 */
218function allowed(ctx: Ctx, f: Finding, paths: readonly Path[], found: readonly Target[]): boolean {
219  if (ctx.allow.length === 0 || paths.length === 0 || found.length !== paths.length) return false
220  if (!plainSteps(f.cd) || paths.some(p => !p.plain)) return false
221  return found.every(t => t.stat.realPath !== undefined && isAllowed(t.abs, ctx.root, ctx.allow) && isAllowed(t.stat.realPath, ctx.rootReal, ctx.allow))
222}
223
224// ---------------------------------------------------------------- deletions
225
226/** rm -r, and git clean once its dry run has named what it removes. */
227async function remove(ctx: Ctx, f: Finding, where: string, notes: LastLookRow[], dir: string, paths: readonly Path[], from: 'words' | 'git-clean'): Promise<LastLookItem> {
228  const found = await existing(ctx, paths)
229  const name = (abs: string) => show(abs, ctx.cwd, ctx.home)
230  if (found.length === 0) {
231    const names = list(paths.map(p => name(p.abs)))
232    if (from === 'words' && paths.every(p => p.plain) && plainSteps(f.cd)) {
233      return item(f, where, { effect: 'nothing', summary: `Nothing found at these paths: ${names}`, tone: 'warning', rows: [...notes, { text: 'Held anyway: check these are the paths you mean.', tone: 'subtle' }] })
234    }
235    const why = from === 'git-clean' ? `its dry run named ${names}, which were not found where expected` : `nothing was found at ${names}, but the shell may read these paths differently`
236    return unmeasured(f, where, why, notes)
237  }
238  const doomed = found.find(t => isCatastrophic(t.abs, ctx.home) || isCatastrophic(t.real, ctx.home))
239  if (doomed) {
240    return item(f, where, { summary: `It deletes ${name(doomed.abs)}: your home folder or a system folder. Too large to measure.`, tone: 'error', rows: notes, isCatastrophic: true })
241  }
242  if (from === 'words' && allowed(ctx, f, paths, found)) return allowedItem(f, where, notes)
243
244  const [tree, view] = await Promise.all([measureTree(ctx, dir, found), gitView(ctx, dir, found.map(t => t.real), true)])
245  const display = (real: string) => {
246    const owner = found.find(t => within(t.real, real))
247    return show(owner ? rebase(real, owner.real, owner.abs) : fromReal(ctx, real), ctx.cwd, ctx.home)
248  }
249  const rows = [...notes]
250  const missing = paths.filter(p => !found.some(t => t.abs === p.abs))
251  if (missing.length > 0) rows.push({ text: `Not found: ${list(missing.map(p => name(p.abs)))}`, tone: 'warning' })
252  if (found.length > 1 && found.length <= 12) {
253    for (const t of found) {
254      const counted = tree.perTarget.get(t.real)
255      const files = counted?.files ?? (t.stat.kind === 'dir' ? 0 : 1)
256      const bytes = counted?.bytes ?? (t.stat.isLink ? 0 : t.stat.size)
257      rows.push({ text: `${name(t.abs)}${t.stat.kind === 'dir' ? '/' : ''}  ${count(files, 'file')}, ${size(bytes)}`, tone: 'subtle' })
258    }
259  }
260  const links = found.filter(t => t.stat.isLink && !t.slash)
261  if (links.length > 0) rows.push({ text: `${list(links.map(t => name(t.abs)))}: a link, so only the link goes`, tone: 'subtle' })
262
263  const lost = typeof view === 'object' ? [...view.changed].filter(([path, xy]) => !xy.includes('D') && found.some(t => within(t.real, path))) : []
264  rows.push(...gitRows(view, lost, display, 'any of this'))
265  if (typeof view === 'object' && view.tracked && view.tracked.size > 0) {
266    rows.push({ text: `git can restore the last committed version of ${count(view.tracked.size, 'tracked file')}`, tone: 'subtle' })
267  }
268  const lostPaths = new Set(lost.map(([path]) => path))
269  const sample = tree.sample.filter(path => !lostPaths.has(path))
270  for (const path of sample.slice(0, MAX_ROWS)) rows.push({ text: display(path), tone: 'subtle' })
271  const unlisted = Math.max(0, tree.files - lost.length - Math.min(sample.length, MAX_ROWS))
272  rows.push(...tree.problems.map(text => ({ text, tone: 'warning' as const })))
273
274  if (tree.isBlind) return unmeasured(f, where, tree.problems.join('; '), rows)
275  const folders = found.filter(t => t.stat.kind === 'dir').length
276  const summary = tree.files > 0 || folders === 0
277    ? `Deletes ${tree.isLowerBound ? 'at least ' : ''}${count(tree.files, 'file')}, ${size(tree.bytes)}`
278    : `Deletes ${count(folders, 'empty folder')}`
279  return item(f, where, { summary, rows, moreRows: unlisted, files: tree.files, bytes: tree.bytes, unsaved: lost.length })
280}
281
282type Tree = {
283  files: number
284  bytes: number
285  isLowerBound: boolean
286  perTarget: Map<string, { files: number; bytes: number }>
287  /** Real paths of some of the files, for the list. */
288  sample: string[]
289  problems: string[]
290  /** Neither the list nor the size could be read. */
291  isBlind: boolean
292}
293
294async function measureTree(ctx: Ctx, dir: string, targets: readonly Target[]): Promise<Tree> {
295  const folders = targets.filter(t => t.stat.kind === 'dir' && (!t.stat.isLink || t.slash))
296  const singles = targets.filter(t => !folders.includes(t))
297  const tree: Tree = {
298    files: singles.length,
299    bytes: singles.reduce((sum, t) => sum + (t.stat.isLink ? 0 : t.stat.size), 0),
300    isLowerBound: false,
301    perTarget: new Map(),
302    sample: singles.map(t => t.real),
303    problems: [],
304    isBlind: false,
305  }
306  if (folders.length === 0) return tree
307  const reals = folders.map(t => t.real).slice(0, MAX_ARGS)
308  const [listing, sizes] = await Promise.all([
309    run(ctx, ['find', ...reals, '-type', 'f', '-print0'], dir, LIST_MS).catch(() => undefined),
310    run(ctx, ['du', '-sk', ...reals], dir, LIST_MS).catch(() => undefined),
311  ])
312  if (listing) {
313    const files = nulList(listing)
314    tree.files += files.length
315    tree.isLowerBound = listing.isStdoutTruncated || listing.exitCode !== 0
316    tree.sample.push(...files.slice(0, MAX_ARGS))
317    if (reals.length <= 20) {
318      for (const real of reals) tree.perTarget.set(real, { files: files.filter(path => within(real, path)).length, bytes: 0 })
319    }
320    if (listing.exitCode !== 0) tree.problems.push('Some folders could not be read, so the count may be short.')
321  } else tree.problems.push('Could not list the files.')
322  if (sizes) {
323    for (const [real, kib] of duLines(sizes.stdout)) {
324      tree.bytes += kib * 1024
325      const counted = tree.perTarget.get(real)
326      if (counted) counted.bytes = kib * 1024
327    }
328  } else tree.problems.push('Could not add up the size.')
329  tree.isBlind = !listing && !sizes
330  return tree
331}
332
333async function findDelete(ctx: Ctx, f: Finding, where: string, notes: LastLookRow[], dir: string): Promise<LastLookItem> {
334  const listing = f.findListing
335  if (!listing) return unmeasured(f, where, 'its find expression could not be re-run safely', notes)
336  // Never run a find that could write or execute, whatever built the listing.
337  if (listing.some(t => FIND_WRITES.has(t))) return unmeasured(f, where, 'its find expression writes or runs commands', notes)
338  const starts = f.targets.map(w => ({ abs: join(dir, w.text), slash: false, plain: isPlainPath(w) }))
339  const names = list(starts.map(p => show(p.abs, ctx.cwd, ctx.home)))
340  if (starts.some(p => isCatastrophic(p.abs, ctx.home))) {
341    return unmeasured(f, where, `it searches ${names}, too much to measure`, notes)
342  }
343  const found = await existing(ctx, starts)
344  if (found.length === 0) {
345    if (starts.every(p => p.plain) && plainSteps(f.cd)) {
346      return item(f, where, { effect: 'nothing', summary: `Nothing found at these paths: ${names}`, tone: 'warning', rows: [...notes, { text: 'Held anyway: check these are the paths you mean.', tone: 'subtle' }] })
347    }
348    return unmeasured(f, where, `nothing was found at ${names}, but the shell may read these paths differently`, notes)
349  }
350  // alwaysAllow never covers find: -L, -H or -follow (anywhere) reach through links.
351
352  const dirReal = (await stat(ctx, dir))?.realPath ?? dir
353  const out = await run(ctx, ['find', ...listing], dirReal, LIST_MS).catch(() => undefined)
354  if (!out) return unmeasured(f, where, 'the find listing did not finish', notes)
355  const matched = nulList(out).map(path => join(dirReal, path))
356  // The shell's find (bfs, GNU) and the system find can disagree: an error or an
357  // empty listing says nothing about what the real command deletes.
358  if (out.exitCode !== 0) {
359    return unmeasured(f, where, `the system find stopped with an error (${clip(firstLine(out.stderr), 100)}); the shell's find may read it differently`, notes)
360  }
361  if (matched.length === 0) return unmeasured(f, where, "the system find lists nothing for it, but the shell's find may match differently", notes)
362  const display = (real: string) => show(fromReal(ctx, real), ctx.cwd, ctx.home)
363  const topmost = matched.filter(path => !matched.some(other => other !== path && within(other, path)))
364  const [sizes, view] = await Promise.all([
365    run(ctx, ['du', '-sk', ...topmost.slice(0, MAX_ARGS)], dirReal, LIST_MS).catch(() => undefined),
366    gitView(ctx, dirReal, found.map(s => s.real), false),
367  ])
368  const bytes = sizes ? duLines(sizes.stdout).reduce((sum, [, kib]) => sum + kib * 1024, 0) : 0
369  const matchedSet = new Set(matched)
370  const lost = typeof view === 'object' ? [...view.changed].filter(([path, xy]) => !xy.includes('D') && matchedSet.has(path)) : []
371  const lostPaths = new Set(lost.map(([path]) => path))
372  const rows = [...notes, ...gitRows(view, lost, display, 'these files')]
373  const sample = matched.filter(path => !lostPaths.has(path)).slice(0, MAX_ROWS)
374  for (const path of sample) rows.push({ text: display(path), tone: 'subtle' })
375  if (!sizes) rows.push({ text: 'Could not add up the size.', tone: 'warning' })
376  rows.push({ text: "Listed with the system find; the shell's find may match a little differently.", tone: 'subtle' })
377  const lower = out.isStdoutTruncated ? 'at least ' : ''
378  return item(f, where, {
379    summary: `Deletes ${lower}${count(matched.length, 'path')}${sizes ? `, ${size(bytes)}` : ''}`,
380    rows,
381    moreRows: Math.max(0, matched.length - lost.length - sample.length),
382    files: matched.length,
383    bytes,
384    unsaved: lost.length,
385  })
386}
387
388async function clean(ctx: Ctx, f: Finding, where: string, notes: LastLookRow[], dir: string, git: { flags: string[]; forces: number; excludes: string[]; paths: Word[] }): Promise<LastLookItem> {
389  const unknownPath = git.paths.find(w => !w.known)
390  if (unknownPath) return unmeasured(f, where, `it names ${unknownPath.raw}, which is only known when it runs`, notes)
391  // The same clean as a dry run: -n lists and never removes, even beside -f.
392  // The forces are passed too: -ff also removes nested git repositories.
393  const forces = Array.from({ length: Math.min(git.forces, 2) }, () => '-f')
394  const args = ['clean', '-n', ...forces, ...git.flags.map(flag => `-${flag}`), ...git.excludes.flatMap(e => ['-e', e]), '--', ...git.paths.map(w => w.text)]
395  const out = await gitRun(ctx, dir, args)
396  if (out.exitCode !== 0) {
397    const why = /not a git repository/i.test(out.stderr) ? 'it is not in a git repository' : `git clean -n failed: ${clip(firstLine(out.stderr), 120)}`
398    return unmeasured(f, where, why, notes)
399  }
400  const lines = out.stdout.split('\n')
401  const names = lines.flatMap(line => (line.startsWith('Would remove ') ? [unquote(line.slice('Would remove '.length))] : []))
402  const skipped = lines.flatMap(line => (line.startsWith('Would skip repository ') ? [unquote(line.slice('Would skip repository '.length))] : []))
403  const rows = [...notes, ...skipped.map(name => ({ text: `It skips the nested repository ${name}.`, tone: 'subtle' as const }))]
404  if (names.length === 0) return unmeasured(f, where, 'its dry run (git clean -n) lists nothing to remove', rows)
405  return remove(ctx, f, where, rows, dir, names.map(name => ({ abs: join(dir, name), slash: false, plain: false })), 'git-clean')
406}
407
408/** git's C-style quoting of an odd name: `"notes \"v2\".md"`. */
409export function unquote(name: string): string {
410  if (!(name.length >= 2 && name.startsWith('"') && name.endsWith('"'))) return name
411  const escapes: Record<string, string> = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', '"': '"', '\\': '\\' }
412  const bytes: number[] = []
413  const body = name.slice(1, -1)
414  for (let k = 0; k < body.length; k++) {
415    const c = body[k] ?? ''
416    if (c !== '\\') {
417      for (const byte of new TextEncoder().encode(c)) bytes.push(byte)
418      continue
419    }
420    const octal = /^[0-7]{3}/.exec(body.slice(k + 1))
421    if (octal) {
422      bytes.push(Number.parseInt(octal[0], 8))
423      k += 3
424    } else {
425      const next = body[k + 1] ?? ''
426      for (const byte of new TextEncoder().encode(escapes[next] ?? next)) bytes.push(byte)
427      k += 1
428    }
429  }
430  return new TextDecoder().decode(new Uint8Array(bytes))
431}
432
433async function overwrite(ctx: Ctx, f: Finding, where: string, notes: LastLookRow[], dir: string): Promise<LastLookItem> {
434  const paths = await expand(ctx, dir, f.targets)
435  if (typeof paths === 'string') return unmeasured(f, where, paths, notes)
436  const path = paths[0]
437  if (!path) return unmeasured(f, where, 'it names no file', notes)
438  // The exception holds only for a command that is this redirect alone: in
439  // `mv a tmp && echo x > tmp` the target exists by the time the redirect runs.
440  const isPlain = path.plain && plainSteps(f.cd) && f.isSole === true
441  // `stat` answers undefined only when the path positively does not exist; any
442  // other failure throws, and the part is held unmeasured.
443  const found = await stat(ctx, path.abs)
444  const name = show(path.abs, ctx.cwd, ctx.home)
445  // The one proof that lets a redirect through: a plain path that does not exist.
446  if (!found) {
447    return isPlain
448      ? harmless(f, where, `Creates ${name}; nothing is overwritten`, notes)
449      : unmeasured(f, where, `nothing was found at ${name}, but the shell may read the path differently`, notes)
450  }
451  // A folder, a link (even to nowhere), a device: no proof either way, so held.
452  if (found.kind !== 'file' || found.isLink) {
453    if (found.kind !== 'file') return unmeasured(f, where, `${name} is not a plain file`, notes)
454    notes = [...notes, { text: `${name} is a link: the file it leads to is overwritten.`, tone: 'warning' }]
455  }
456  const real = found.realPath
457
458  const view = await gitView(ctx, dir, [real ?? path.abs], true)
459  const rows = [...notes]
460  let unsaved = 0
461  if (view === 'none') rows.push({ text: 'Not in a git repository: its old contents would be gone for good.', tone: 'warning' })
462  else if (view === 'failed') rows.push({ text: 'git status failed, so unsaved work could not be checked.', tone: 'warning' })
463  else {
464    const xy = view.changed.get(real ?? path.abs)
465    if (xy !== undefined && !xy.includes('D')) {
466      unsaved = 1
467      rows.push({ text: `It has unsaved work git cannot bring back (${describe(xy)}).`, tone: 'error' })
468    } else if (view.tracked?.has(real ?? path.abs)) rows.push({ text: 'git can restore it as last committed.', tone: 'subtle' })
469    else rows.push({ text: 'git ignores it: its old contents would be gone for good.', tone: 'warning' })
470  }
471  return item(f, where, { summary: `Overwrites ${name} (${size(found.size)})`, rows, files: 1, bytes: found.size, unsaved })
472}
473
474// ---------------------------------------------------------------- git
475
476async function discard(ctx: Ctx, f: Finding, where: string, notes: LastLookRow[], dir: string): Promise<LastLookItem> {
477  const git = f.git
478  const top = await gitRun(ctx, dir, ['rev-parse', '--show-toplevel'])
479  if (top.exitCode !== 0) return unmeasured(f, where, 'it is not in a git repository', notes)
480  const root = top.stdout.trim()
481  const display = (real: string) => show(fromReal(ctx, real), ctx.cwd, ctx.home)
482
483  let specs: string[] = []
484  let fromIndex = false
485  let ref: string | undefined
486  if (git?.sub === 'reset') {
487    if (git.ref && !git.ref.known) return unmeasured(f, where, `it resets to ${git.ref.raw}, which is only known when it runs`, notes)
488    ref = git.ref?.text
489  } else if (git?.sub === 'discard') {
490    const unknownPath = git.paths.find(w => !w.known)
491    if (unknownPath) return unmeasured(f, where, `it names ${unknownPath.raw}, which is only known when it runs`, notes)
492    if (git.mode === 'maybe-ref') {
493      // `git checkout x` passes only as a proven branch switch (git refuses a
494      // switch that would lose changes); every doubt is held as paths.
495      const name = git.paths[0]?.text ?? ''
496      if (await isBranchSwitch(ctx, dir, name)) return harmless(f, where, `Switches to branch ${name}; git refuses a switch that would lose changes`, notes)
497    }
498    if (git.mode !== 'all') specs = ['--', ...git.paths.map(w => w.text)]
499    fromIndex = git.fromIndex && git.mode !== 'all'
500  } else return unmeasured(f, where, 'unexpected git form', notes)
501
502  if (ref !== undefined && ref !== 'HEAD') {
503    const known = await gitRun(ctx, dir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
504    if (known.exitCode !== 0) return unmeasured(f, where, `git cannot find ${ref}`, notes)
505  }
506  const dropsCommits = ref !== undefined && ref !== 'HEAD'
507  // diff-files and diff-index read the index without refreshing it: `git diff` would rewrite it.
508  const numstatArgs = fromIndex ? ['diff-files', '--numstat', ...specs] : ['diff-index', '--numstat', 'HEAD', ...specs]
509  const [status, numstat, branch, dropped, log] = await Promise.all([
510    gitRun(ctx, dir, ['status', '--porcelain=v1', '-z', '--untracked-files=no', ...specs]),
511    gitRun(ctx, dir, numstatArgs),
512    gitRun(ctx, dir, ['rev-parse', '--abbrev-ref', 'HEAD']),
513    dropsCommits ? gitRun(ctx, dir, ['rev-list', '--count', '--end-of-options', `${ref}..HEAD`]) : undefined,
514    dropsCommits ? gitRun(ctx, dir, ['log', '--format=%h %s', '-n', '8', '--end-of-options', `${ref}..HEAD`]) : undefined,
515  ])
516  if (status.exitCode !== 0) return unmeasured(f, where, `git status failed: ${clip(firstLine(status.stderr), 120)}`, notes)
517  if (dropped && dropped.exitCode !== 0) return unmeasured(f, where, `git could not count the commits it drops: ${clip(firstLine(dropped.stderr), 120)}`, notes)
518  const changes = [...porcelain(status.stdout, root)].filter(([, xy]) => (fromIndex ? xy[1] !== ' ' : true))
519  const lines = numstatLines(numstat.stdout, root)
520  const commits = dropped ? Number.parseInt(dropped.stdout.trim(), 10) || 0 : 0
521  if (changes.length === 0 && commits === 0) {
522    return item(f, where, { effect: 'nothing', summary: 'Nothing to lose as far as git can tell: no uncommitted changes there', tone: 'warning', rows: notes })
523  }
524  const rows = [...notes]
525  let added = 0
526  let removed = 0
527  if (changes.length > 0) {
528    rows.push({ text: `${count(changes.length, 'file')} with uncommitted changes git cannot bring back:`, tone: 'error' })
529    for (const [path, xy] of changes) {
530      const stats = lines.get(path)
531      added += stats?.added ?? 0
532      removed += stats?.removed ?? 0
533      rows.push({ text: `  ${display(path)}  ${stats ? `+${stats.added} -${stats.removed}` : describe(xy)}`, tone: 'error' })
534    }
535  }
536  const onBranch = branch.exitCode === 0 ? branch.stdout.trim() : 'this branch'
537  if (commits > 0) {
538    rows.push({ text: `${count(commits, 'commit')} would leave ${onBranch}:`, tone: 'error' })
539    for (const line of (log?.stdout ?? '').split('\n').filter(Boolean)) rows.push({ text: `  ${line}`, tone: 'subtle' })
540    rows.push({ text: 'git reflog can still find dropped commits for a while.', tone: 'subtle' })
541  }
542  const parts = [
543    ...(changes.length > 0 ? [`Discards uncommitted changes in ${count(changes.length, 'file')} (+${added} -${removed} lines)`] : []),
544    ...(commits > 0 ? [`drops ${count(commits, 'commit')} from ${onBranch}`] : []),
545  ]
546  return item(f, where, { summary: capitalize(parts.join(' and ')), tone: 'error', rows, files: changes.length, unsaved: changes.length, commits })
547}
548
549/**
550 * True only when git positively says `name` is a branch: a local branch, a
551 * remote-tracking branch, or exactly one remote branch git would set up for
552 * it; and no file or folder of that name makes the checkout ambiguous. A git
553 * error answers false; a stat error throws (held).
554 */
555async function isBranchSwitch(ctx: Ctx, dir: string, name: string): Promise<boolean> {
556  if (!/^[A-Za-z0-9._/-]+$/.test(name) || name.startsWith('-') || name.includes('..')) return false
557  const local = await gitRun(ctx, dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`])
558  let isBranch = local.exitCode === 0
559  if (!isBranch) isBranch = (await gitRun(ctx, dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${name}`])).exitCode === 0
560  if (!isBranch) {
561    const guess = await gitRun(ctx, dir, ['for-each-ref', '--format=%(refname)', `refs/remotes/*/${name}`])
562    isBranch = guess.exitCode === 0 && guess.stdout.split('\n').filter(Boolean).length === 1
563  }
564  if (!isBranch) return false
565  return (await stat(ctx, join(dir, name))) === undefined
566}
567
568async function push(ctx: Ctx, f: Finding, where: string, notes: LastLookRow[], dir: string): Promise<LastLookItem> {
569  const git = f.git
570  if (git?.sub !== 'push') return unmeasured(f, where, 'unexpected git form', notes)
571  if (git.isWholeRepo) return unmeasured(f, where, 'it force-pushes many branches at once', notes)
572  if (git.remote && !git.remote.known) return unmeasured(f, where, `it pushes to ${git.remote.raw}, which is only known when it runs`, notes)
573  const top = await gitRun(ctx, dir, ['rev-parse', '--show-toplevel'])
574  if (top.exitCode !== 0) return unmeasured(f, where, 'it is not in a git repository', notes)
575
576  type Pair = { local: string; name: string; isDelete: boolean }
577  const pairs: Pair[] = []
578  const branch = (await gitRun(ctx, dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
579  if (!git.remote) {
580    let upstream = await gitRun(ctx, dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{push}'])
581    if (upstream.exitCode !== 0) upstream = await gitRun(ctx, dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
582    if (upstream.exitCode !== 0) return unmeasured(f, where, 'no upstream branch is set, so what it would overwrite is unclear', notes)
583    pairs.push({ local: 'HEAD', name: upstream.stdout.trim(), isDelete: false })
584  } else if (git.refspecs.length === 0) {
585    pairs.push({ local: 'HEAD', name: `${git.remote.text}/${branch}`, isDelete: false })
586  } else {
587    for (const spec of git.refspecs) {
588      if (!spec.known) return unmeasured(f, where, `it pushes ${spec.raw}, which is only known when it runs`, notes)
589      const [src = '', dst] = spec.text.replace(/^\+/, '').split(':')
590      const target = (dst ?? src).replace(/^refs\/heads\//, '')
591      pairs.push({ local: src || 'HEAD', name: `${git.remote.text}/${target === 'HEAD' ? branch : target}`, isDelete: src === '' })
592    }
593  }
594
595  const fetchHead = (await gitRun(ctx, dir, ['rev-parse', '--git-path', 'FETCH_HEAD'])).stdout.trim()
596  const fetched = fetchHead ? await stat(ctx, join(dir, fetchHead)) : undefined
597  const age = fetched ? ago(ctx.now - fetched.mtimeMs) : 'never fetched here'
598  const rows = [...notes]
599  let total = 0
600  let isBlind = false
601  for (const pair of pairs.slice(0, 6)) {
602    const remoteRef = `refs/remotes/${pair.name}`
603    if (pair.isDelete) {
604      rows.push({ text: `It deletes ${pair.name} on the remote.`, tone: 'error' })
605      continue
606    }
607    const known = await gitRun(ctx, dir, ['rev-parse', '--verify', '--quiet', remoteRef])
608    if (known.exitCode !== 0) {
609      isBlind = true
610      rows.push({ text: `No record of ${pair.name} here, so what it would overwrite is unknown.`, tone: 'warning' })
611      continue
612    }
613    const range = `${pair.local}..${remoteRef}`
614    const [counted, log] = await Promise.all([
615      gitRun(ctx, dir, ['rev-list', '--count', '--end-of-options', range]),
616      gitRun(ctx, dir, ['log', '--format=%h %an: %s', '-n', '8', '--end-of-options', range]),
617    ])
618    if (counted.exitCode !== 0) {
619      isBlind = true
620      rows.push({ text: `git could not compare with ${pair.name}.`, tone: 'warning' })
621      continue
622    }
623    const n = Number.parseInt(counted.stdout.trim(), 10) || 0
624    total += n
625    if (n > 0) {
626      rows.push({ text: `${count(n, 'commit')} on ${pair.name} would be overwritten:`, tone: 'error' })
627      for (const line of log.stdout.split('\n').filter(Boolean)) rows.push({ text: `  ${line}`, tone: 'subtle' })
628    }
629  }
630  rows.push({ text: `As of your last fetch (${age}); the remote may have moved since.`, tone: 'warning' })
631  const names = list(pairs.map(p => p.name))
632  const summary = total > 0 ? `Overwrites ${names}: ${count(total, 'commit')} there would be lost` : `Force-pushes ${names}; no commits would be lost as of your last fetch`
633  return item(f, where, { effect: isBlind ? 'unknown' : 'some', summary, tone: total > 0 ? 'error' : 'warning', rows, commits: total })
634}
635
636async function branchDelete(ctx: Ctx, f: Finding, where: string, notes: LastLookRow[], dir: string): Promise<LastLookItem> {
637  const git = f.git
638  if (git?.sub !== 'branch') return unmeasured(f, where, 'unexpected git form', notes)
639  const unknownName = git.names.find(w => !w.known)
640  if (unknownName) return unmeasured(f, where, `it deletes ${unknownName.raw}, which is only known when it runs`, notes)
641  const top = await gitRun(ctx, dir, ['rev-parse', '--show-toplevel'])
642  if (top.exitCode !== 0) return unmeasured(f, where, 'it is not in a git repository', notes)
643  const current = (await gitRun(ctx, dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
644  const rows = [...notes]
645  const deleted: string[] = []
646  let total = 0
647  let isBlind = false
648  for (const w of git.names.slice(0, 10)) {
649    const name = w.text
650    const exists = (await gitRun(ctx, dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`])).exitCode === 0
651    if (!exists) {
652      rows.push({ text: `No branch named ${name}.`, tone: 'subtle' })
653      continue
654    }
655    if (name === current) rows.push({ text: `${name} is checked out, so git will refuse to delete it.`, tone: 'subtle' })
656    deleted.push(name)
657    // Commits on this branch and on no other branch, remote or tag.
658    const only = [`refs/heads/${name}`, '--not', `--exclude=${name}`, '--branches', '--remotes', '--tags']
659    const [counted, log] = await Promise.all([gitRun(ctx, dir, ['rev-list', '--count', ...only]), gitRun(ctx, dir, ['log', '--format=%h %s', '-n', '8', ...only])])
660    if (counted.exitCode !== 0) {
661      isBlind = true
662      continue
663    }
664    const n = Number.parseInt(counted.stdout.trim(), 10) || 0
665    total += n
666    if (n > 0) {
667      rows.push({ text: `${count(n, 'commit')} exist only on ${name}:`, tone: 'error' })
668      for (const line of log.stdout.split('\n').filter(Boolean)) rows.push({ text: `  ${line}`, tone: 'subtle' })
669    }
670  }
671  if (deleted.length === 0) return item(f, where, { effect: 'nothing', summary: 'No such branch found', tone: 'warning', rows })
672  if (total > 0) rows.push({ text: 'git reflog can still find them for a while.', tone: 'subtle' })
673  const summary = total > 0 ? `Deletes ${list(deleted)}: ${count(total, 'commit')} exist nowhere else` : `Deletes ${list(deleted)}; every commit is also on another branch`
674  return item(f, where, { effect: isBlind ? 'unknown' : 'some', summary, tone: total > 0 ? 'error' : 'warning', rows, commits: total })
675}
676
677type GitView = { changed: Map<string, string>; tracked?: Set<string> } | 'none' | 'failed'
678
679/** What git knows about these real paths: uncommitted entries, tracked files. */
680async function gitView(ctx: Ctx, dir: string, reals: readonly string[], wantTracked: boolean): Promise<GitView> {
681  const top = await gitRun(ctx, dir, ['rev-parse', '--show-toplevel']).catch(() => undefined)
682  if (!top) return 'failed'
683  if (top.exitCode !== 0) return 'none'
684  const root = top.stdout.trim()
685  const inside = reals.filter(real => within(root, real) && real !== join(root, '.git') && !within(join(root, '.git'), real))
686  if (inside.length === 0) return 'none'
687  const specs = inside.slice(0, MAX_ARGS).map(real => (real === root ? '.' : real.slice(root.length + 1)))
688  // Literal pathspecs: a file named `a[1].txt` is that file, not a pattern.
689  const status = await gitRun(ctx, root, ['--literal-pathspecs', 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...specs]).catch(() => undefined)
690  if (!status || status.exitCode !== 0) return 'failed'
691  const view: GitView = { changed: porcelain(status.stdout, root) }
692  if (wantTracked) {
693    const files = await gitRun(ctx, root, ['--literal-pathspecs', 'ls-files', '-z', '--', ...specs]).catch(() => undefined)
694    if (files && files.exitCode === 0) view.tracked = new Set(nulList(files).map(path => join(root, path)))
695  }
696  return view
697}
698
699function gitRows(view: GitView, lost: [string, string][], display: (real: string) => string, what: string): LastLookRow[] {
700  if (view === 'none') return [{ text: `Not in a git repository: git cannot bring back ${what}.`, tone: 'warning' }]
701  if (view === 'failed') return [{ text: 'git status failed, so unsaved work could not be checked.', tone: 'warning' }]
702  if (lost.length === 0) return []
703  const rows: LastLookRow[] = [{ text: `${count(lost.length, 'file')} ${lost.length === 1 ? 'has' : 'have'} unsaved work you would lose:`, tone: 'error' }]
704  for (const [path, xy] of lost.slice(0, 15)) rows.push({ text: `  ${display(path)}  (${describe(xy)})`, tone: 'error' })
705  if (lost.length > 15) rows.push({ text: `  and ${count(lost.length - 15, 'more file')} with unsaved work`, tone: 'error' })
706  return rows
707}
708
709// ---------------------------------------------------------------- headline
710
711function headline(items: readonly LastLookItem[]): Pick<LastLookReport, 'headline' | 'headlineTone'> {
712  const live = items.filter(i => !i.isAllowed && !i.isHarmless)
713  const total = (key: 'unsaved' | 'commits' | 'files' | 'bytes') => live.reduce((sum, i) => sum + i[key], 0)
714  const unsaved = total('unsaved')
715  const commits = total('commits')
716  if (live.some(i => i.isCatastrophic)) return { headline: 'It deletes your home folder or a system folder', headlineTone: 'error' }
717  if (unsaved > 0) return { headline: `${count(unsaved, 'file')} ${unsaved === 1 ? 'has' : 'have'} unsaved work you would lose`, headlineTone: 'error' }
718  if (commits > 0) return { headline: `${count(commits, 'commit')} would be lost`, headlineTone: 'error' }
719  if (live.some(i => i.effect === 'unknown')) return { headline: 'Could not measure all of it: review it yourself', headlineTone: 'warning' }
720  const files = total('files')
721  if (files > 0) return { headline: `It would delete or overwrite ${count(files, 'file')} (${size(total('bytes'))})`, headlineTone: 'warning' }
722  if (live.length > 0 && live.every(i => i.effect === 'nothing')) return { headline: 'Nothing found to destroy as far as it can tell: review it yourself', headlineTone: 'warning' }
723  return { headline: 'Check what it would change before it runs', headlineTone: 'warning' }
724}
725
726/**
727 * The pane's first line. "Can destroy work" only when the measurement found work to lose;
728 * a command it could not fully check, or found nothing in, is held for review in neutral
729 * words (the headline under it says which), so a harmless step never reads as a threat.
730 */
731export function holdTitle(items: readonly LastLookItem[]): string {
732  const live = items.filter(i => !i.isAllowed && !i.isHarmless)
733  const found = live.some(i => i.isCatastrophic || i.unsaved > 0 || i.commits > 0 || i.files > 0)
734  return found ? 'Held: this command can destroy work' : 'Held for your review'
735}
736
737// ---------------------------------------------------------------- pieces
738
739type Draft = {
740  summary: string
741  tone?: LastLookTone
742  effect?: LastLookItem['effect']
743  rows?: LastLookRow[]
744  moreRows?: number
745  files?: number
746  bytes?: number
747  unsaved?: number
748  commits?: number
749  isAllowed?: boolean
750  isHarmless?: boolean
751  isCatastrophic?: boolean
752}
753
754function blank(title: string): LastLookItem {
755  return { title, effect: 'some', summary: '', summaryTone: 'normal', rows: [], moreRows: 0, files: 0, bytes: 0, unsaved: 0, commits: 0, isAllowed: false, isHarmless: false, isCatastrophic: false }
756}
757
758function item(f: Finding, where: string | undefined, d: Draft): LastLookItem {
759  const rows = d.rows ?? []
760  return {
761    ...blank(clip(f.label, 400)),
762    ...(where !== undefined && where !== '.' ? { where } : {}),
763    effect: d.effect ?? 'some',
764    summary: d.summary,
765    summaryTone: d.tone ?? 'normal',
766    rows: rows.slice(0, MAX_ROWS),
767    moreRows: Math.max(0, rows.length - MAX_ROWS) + (d.moreRows ?? 0),
768    files: d.files ?? 0,
769    bytes: d.bytes ?? 0,
770    unsaved: d.unsaved ?? 0,
771    commits: d.commits ?? 0,
772    isAllowed: d.isAllowed ?? false,
773    isHarmless: d.isHarmless ?? false,
774    isCatastrophic: d.isCatastrophic ?? false,
775  }
776}
777
778function unmeasured(f: Finding, where: string | undefined, why: string, rows: LastLookRow[] = []): LastLookItem {
779  return item(f, where, { effect: 'unknown', summary: COULD_NOT, tone: 'warning', rows: [{ text: capitalize(`${why}.`), tone: 'subtle' }, ...rows] })
780}
781
782function allowedItem(f: Finding, where: string, notes: LastLookRow[]): LastLookItem {
783  return item(f, where, { summary: 'Inside your alwaysAllow folders', rows: notes, isAllowed: true })
784}
785
786/** Not destructive after all; see `isHarmless`. */
787function harmless(f: Finding, where: string, summary: string, notes: LastLookRow[]): LastLookItem {
788  return item(f, where, { effect: 'nothing', summary, rows: notes, isHarmless: true })
789}
790
791async function run(ctx: Ctx, argv: string[], cwd: string, timeoutMs: number): Promise<ProcessRunResult> {
792  return ctx.probe.run(argv, cwd, timeoutMs)
793}
794
795async function gitRun(ctx: Ctx, dir: string, args: string[]): Promise<ProcessRunResult> {
796  return run(ctx, [...GIT, ...args], dir, GIT_MS)
797}
798
799/** Undefined only when the path is not there; any other failure throws, so the part is held unmeasured. */
800async function stat(ctx: Ctx, path: string): Promise<FsStat | undefined> {
801  return ctx.probe.stat(path)
802}
803
804/** A real path as the person knows it: under their cwd's own spelling. */
805function fromReal(ctx: Ctx, real: string): string {
806  return rebase(real, ctx.cwdReal, ctx.cwd)
807}
808
809function nulList(out: ProcessRunResult): string[] {
810  const parts = out.stdout.split('\0')
811  // A cut stream ends mid-path: drop the partial one.
812  if (out.isStdoutTruncated) parts.pop()
813  return parts.filter(Boolean)
814}
815
816/** `du -sk` lines: [path, KiB]. */
817export function duLines(stdout: string): [string, number][] {
818  return stdout.split('\n').flatMap(line => {
819    const m = /^(\d+)\t(.+)$/.exec(line)
820    return m ? [[m[2] ?? '', Number(m[1])] as [string, number]] : []
821  })
822}
823
824/** `git status --porcelain=v1 -z`: real path to its two-letter status. */
825export function porcelain(stdout: string, root: string): Map<string, string> {
826  const out = new Map<string, string>()
827  const parts = stdout.split('\0')
828  for (let k = 0; k < parts.length; k++) {
829    const entry = parts[k] ?? ''
830    if (entry.length < 4) continue
831    const xy = entry.slice(0, 2)
832    out.set(join(root, entry.slice(3)), xy)
833    // A rename or copy is followed by the path it came from.
834    if (xy[0] === 'R' || xy[0] === 'C') k++
835  }
836  return out
837}
838
839function numstatLines(stdout: string, root: string): Map<string, { added: number; removed: number }> {
840  const out = new Map<string, { added: number; removed: number }>()
841  for (const line of stdout.split('\n')) {
842    const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
843    if (m) out.set(join(root, m[3] ?? ''), { added: Number(m[1]) || 0, removed: Number(m[2]) || 0 })
844  }
845  return out
846}
847
848function describe(xy: string): string {
849  if (xy === '??') return 'never added to git'
850  if (xy.includes('U')) return 'merge conflict'
851  if (xy[0] === 'A') return 'new, staged'
852  if (xy[0] === 'R') return 'renamed'
853  if (xy[1] === 'M' || xy[1] === 'T') return 'modified'
854  if (xy[0] === 'M') return 'staged changes'
855  if (xy.includes('D')) return 'deleted'
856  return 'changed'
857}
858
859export function count(n: number, noun: string): string {
860  return `${grouped(n)} ${n === 1 ? noun : `${noun}s`}`
861}
862
863export function grouped(n: number): string {
864  return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
865}
866
867export function size(bytes: number): string {
868  const units = ['B', 'KB', 'MB', 'GB', 'TB']
869  let value = bytes
870  let unit = 0
871  while (value >= 1024 && unit < units.length - 1) {
872    value /= 1024
873    unit++
874  }
875  return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${units[unit]}`
876}
877
878function ago(ms: number): string {
879  const minutes = Math.round(ms / 60_000)
880  if (minutes < 2) return 'just now'
881  if (minutes < 90) return `${minutes} min ago`
882  const hours = Math.round(minutes / 60)
883  if (hours < 36) return `${hours} h ago`
884  return `${Math.round(hours / 24)} days ago`
885}
886
887function list(names: readonly string[]): string {
888  if (names.length <= 3) return names.join(', ')
889  return `${names.slice(0, 3).join(', ')} and ${names.length - 3} more`
890}
891
892function firstLine(text: string): string {
893  return text.trim().split('\n')[0] ?? ''
894}
895
896function clip(text: string, max: number): string {
897  return text.length <= max ? text : `${text.slice(0, max - 3)}...`
898}
899
900function capitalize(text: string): string {
901  return text.charAt(0).toUpperCase() + text.slice(1)
902}
903
904function short(error: unknown): string {
905  const message = error instanceof Error ? error.message : String(error)
906  return clip(message, 120)
907}
908
hooks/paths.ts 91 lines
1// Pure POSIX path helpers, and the alwaysAllow match.
2
3export function isAbsolute(path: string): boolean {
4  return path.startsWith('/')
5}
6
7/** Folds `.`, `..` and repeated slashes; keeps a lone `/`. */
8export function normalize(path: string): string {
9  const parts: string[] = []
10  for (const part of path.split('/')) {
11    if (part === '' || part === '.') continue
12    if (part === '..') parts.pop()
13    else parts.push(part)
14  }
15  return `/${parts.join('/')}`
16}
17
18export function join(dir: string, path: string): string {
19  return normalize(isAbsolute(path) ? path : `${dir}/${path}`)
20}
21
22/** True when `path` is `parent` or lies beneath it. */
23export function within(parent: string, path: string): boolean {
24  if (parent === '/') return true
25  return path === parent || path.startsWith(`${parent}/`)
26}
27
28/** Moves `path` from beneath `from` to beneath `to`, or leaves it as it is. */
29export function rebase(path: string, from: string, to: string): string {
30  if (!within(from, path)) return path
31  return normalize(to + path.slice(from.length))
32}
33
34/** How a path reads to the person: relative to where they are, else `~/...`. */
35export function show(path: string, cwd: string, home?: string): string {
36  if (path === cwd) return '.'
37  if (within(cwd, path)) return path.slice(cwd === '/' ? 1 : cwd.length + 1)
38  if (home && within(home, path)) return path === home ? '~' : `~${path.slice(home.length)}`
39  return path
40}
41
42/** The alwaysAllow setting: a comma list of folders, `./` and trailing `/` dropped. */
43export function parseAllow(setting: string): string[] {
44  return setting
45    .split(',')
46    .map(entry => entry.trim().replace(/^(\.\/)+/, '').replace(/\/+$/, ''))
47    .filter(entry => entry !== '' && entry !== '.' && !entry.split('/').includes('..'))
48}
49
50/**
51 * True when `path` lies inside an allowed folder: an absolute entry by
52 * prefix; a relative one (`dist`, `node_modules/.cache`) as a run of folder
53 * names anywhere beneath the project root, never the root itself or outside.
54 */
55export function isAllowed(path: string, root: string, allow: readonly string[]): boolean {
56  return allow.some(entry => {
57    if (isAbsolute(entry)) return within(normalize(entry), path)
58    if (!within(root, path) || path === root) return false
59    const segments = path.slice(root === '/' ? 1 : root.length + 1).split('/')
60    const wanted = entry.split('/')
61    for (let start = 0; start + wanted.length <= segments.length; start++) {
62      if (wanted.every((name, k) => segments[start + k] === name)) return true
63    }
64    return false
65  })
66}
67
68/** A glob segment (`*.log`, `b?ild`, `[ab]*`) as a RegExp, as bash matches names. */
69export function globSegment(segment: string): RegExp {
70  let source = ''
71  for (let k = 0; k < segment.length; k++) {
72    const c = segment[k] ?? ''
73    if (c === '*') source += '[^/]*'
74    else if (c === '?') source += '[^/]'
75    else if (c === '[') {
76      const close = segment.indexOf(']', k + 2)
77      if (close === -1) source += '\\['
78      else {
79        const body = segment.slice(k + 1, close).replace(/^!/, '^').replace(/\\/g, '\\\\')
80        source += `[${body}]`
81        k = close
82      }
83    } else source += c.replace(/[.+^${}()|\\\]]/g, '\\$&')
84  }
85  return new RegExp(`^${source}$`)
86}
87
88export function hasGlob(segment: string): boolean {
89  return /[*?[]/.test(segment)
90}
91
hooks/text.ts 19 lines
1// Untrusted text (the model's command, file names, branch names, commit
2// subjects) made safe to draw. The engine refuses a whole tree holding a C0
3// or C1 control character and draws its own instead, which would hide the
4// hold and its Cancel button, so every such text goes through here first.
5
6const ANSI = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]?/g
7const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
8/** Bidi overrides and zero-width marks: they can make a command read differently from what runs. */
9const INVISIBLE = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g
10
11/** One drawable line: ANSI sequences gone, line breaks and tabs as spaces, other controls as U+FFFD. */
12export function clean(text: string): string {
13  return text
14    .replace(ANSI, '')
15    .replace(/[\r\n\t]/g, ' ')
16    .replace(CONTROL, '�')
17    .replace(INVISIBLE, '�')
18}
19
hooks/view.tsx 156 lines
1// Draws one held command: what it is, what it would destroy, Cancel and
2// Proceed. One tree for the pane and, compact, for the band above the prompt.
3
4import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, RenderSurface, TextProps } from 'claude-code'
5
6import type { LastLookHold, LastLookItem, LastLookRow, LastLookTone } from '../types'
7import { holdTitle } from './measure'
8import { clean } from './text'
9
10export type Kit = {
11  Box: ElementConstructor<BoxProps>
12  Text: ElementConstructor<TextProps>
13  Button: ElementConstructor<ButtonProps>
14}
15
16export type Room = {
17  /** Rows the tree may take before it scrolls. */
18  rows: number
19  /** Cells across the body (the site's own width, never the terminal's). */
20  columns: number
21  compact: boolean
22  surface: RenderSurface
23  /** How many more holds wait behind this one. */
24  queued: number
25  onCancel: () => void
26  onProceed: () => void
27}
28
29// Proceed hands the command on: Claude Code's own permission check may still ask.
30export const FOOTER = "A safety net, not a permission system. Proceed passes it on to Claude Code's own checks."
31
32function tone(t: LastLookTone | undefined): TextProps {
33  if (t === 'error') return { color: 'error' }
34  if (t === 'warning') return { color: 'warning' }
35  if (t === 'subtle') return { dimColor: true }
36  return {}
37}
38
39export function drawHold(kit: Kit, hold: LastLookHold, room: Room): RenderElement {
40  const { Box, Text, Button } = kit
41  const { report } = hold
42  // The site's own width: never wider than it, so nothing draws under its marks.
43  const width = Math.max(1, room.columns)
44  // Cancel first, so it is the one focused (`autoFocus`, first drawn wins)
45  // and Enter cancels. Proceed has NO hotkey: no keystroke typed at the site
46  // may ever approve. It takes a click, or Tab onto it and then Enter.
47  const buttons = (
48    <Box flexDirection="row" gap={2}>
49      <Button key="cancel" label="Cancel" hotkey="c" variant="primary" autoFocus onPress={room.onCancel} />
50      <Button key="proceed" label="Proceed" onPress={room.onProceed} />
51    </Box>
52  )
53  const hint = room.surface === 'terminal' ? 'Click, or ctrl+x tab: Enter or c cancels; Tab to Proceed, then Enter' : undefined
54
55  if (room.compact) {
56    // The band: one line per part, so the buttons stay in its rows.
57    const free = Math.max(0, room.rows - 6 - (room.queued > 0 ? 1 : 0))
58    const shown = report.items.slice(0, free)
59    return (
60      <Box key="last-look" flexDirection="column" width={width}>
61        <Box key="command" flexDirection="row">
62          <Text bold color="warning">Last Look held: </Text>
63          <Text wrap="truncate-end">{clean(report.command)}</Text>
64        </Box>
65        <Box key="headline">
66          <Text bold {...tone(report.headlineTone)} wrap="truncate-end">{clean(report.headline)}</Text>
67        </Box>
68        {shown.map((item, k) => (
69          <Box key={`item-${k}`}>
70            <Text {...tone(item.summaryTone)} wrap="truncate-end">{clean(`${item.title}: ${item.summary}`)}</Text>
71          </Box>
72        ))}
73        {report.items.length > shown.length && <Text dimColor>{`and ${report.items.length - shown.length} more parts`}</Text>}
74        {room.queued > 0 && <Text dimColor>{`${room.queued} more held after this one`}</Text>}
75        {buttons}
76        <Box key="alone">
77          <Text dimColor wrap="truncate-end">Other bands are hidden until you decide.</Text>
78        </Box>
79        <Box key="footer">
80          <Text dimColor wrap="truncate-end">{hint ? `${FOOTER} ${hint}` : FOOTER}</Text>
81        </Box>
82      </Box>
83    )
84  }
85
86  // The pane: spend the rows it has on detail, keep the buttons in view.
87  const commandRows = Math.min(3, Math.ceil((report.command.length + 2) / width))
88  const fixed = 1 + commandRows + (hold.isSubagent ? 1 : 0) + 2 + 3 + (hint ? 1 : 0) + (room.queued > 0 ? 1 : 0)
89  const budget = Math.max(report.items.length, room.rows - fixed - report.items.length * 3)
90  const shares = share(report.items, budget)
91  return (
92    <Box flexDirection="column" width={width}>
93      <Text bold color="warning">{holdTitle(report.items)}</Text>
94      <Box key="command">
95        <Text wrap="wrap">{`$ ${clip(clean(report.command), width * 3 - 2)}`}</Text>
96      </Box>
97      {hold.isSubagent && <Text dimColor>Asked by a subagent.</Text>}
98      <Box key="headline" marginTop={1}>
99        <Text bold {...tone(report.headlineTone)} wrap="wrap">{clean(report.headline)}</Text>
100      </Box>
101      {report.items.map((item, k) => drawItem(kit, item, k, shares[k] ?? 0))}
102      {room.queued > 0 && <Text dimColor>{`${room.queued} more held after this one`}</Text>}
103      <Box marginTop={1}>{buttons}</Box>
104      <Box key="footer" flexDirection="column">
105        <Text dimColor>{FOOTER}</Text>
106        {hint && <Text dimColor>{hint}</Text>}
107      </Box>
108    </Box>
109  )
110}
111
112function drawItem(kit: Kit, item: LastLookItem, index: number, room: number): RenderElement {
113  const { Box, Text } = kit
114  const rows = item.rows.slice(0, room)
115  const hidden = item.rows.length - rows.length + item.moreRows
116  return (
117    <Box key={`item-${index}`} flexDirection="column" marginTop={1}>
118      <Box flexDirection="row">
119        <Text bold wrap="truncate-end">{clean(item.title)}</Text>
120        {item.where !== undefined && <Text dimColor>{`  in ${clean(item.where)}`}</Text>}
121      </Box>
122      <Text {...tone(item.summaryTone)} wrap="wrap">{clean(item.summary)}</Text>
123      {rows.map((row: LastLookRow) => (
124        <Text {...tone(row.tone)} wrap="truncate-middle">{`  ${clean(row.text)}`}</Text>
125      ))}
126      {hidden > 0 && <Text dimColor>{`  and ${grouped(hidden)} more`}</Text>}
127    </Box>
128  )
129}
130
131/** Splits `budget` rows across items, each getting what it needs up to a fair part. */
132export function share(items: readonly LastLookItem[], budget: number): number[] {
133  const out: number[] = items.map(() => 0)
134  let left = Math.max(0, budget)
135  let open = items.map((_, k) => k).filter(k => (items[k]?.rows.length ?? 0) > 0)
136  while (left > 0 && open.length > 0) {
137    const each = Math.max(1, Math.floor(left / open.length))
138    for (const k of open) {
139      const want = (items[k]?.rows.length ?? 0) - (out[k] ?? 0)
140      const give = Math.min(each, want, left)
141      out[k] = (out[k] ?? 0) + give
142      left -= give
143    }
144    open = open.filter(k => (out[k] ?? 0) < (items[k]?.rows.length ?? 0))
145  }
146  return out
147}
148
149function grouped(n: number): string {
150  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
151}
152
153function clip(text: string, max: number): string {
154  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 3))}...`
155}
156
hooks/shell.ts 402 lines
1// A small, forgiving shell parser: just enough of bash's grammar to see which
2// commands a string runs, with which words and redirects, in which order.
3//
4// It never throws and always ends: on input bash would reject (an open quote,
5// a stray paren) it keeps going with its best reading, because a guard that
6// stops at a parse error would let the rest of the string through unread.
7
8/** One piece of a word, as written. */
9export type Part =
10  | { t: 'lit'; s: string; quoted: boolean }
11  /** `quoted`: inside double quotes, so the shell neither splits nor globs its value. */
12  | { t: 'var'; name: string; quoted: boolean }
13  /** `$( ... )`, backticks or `<( ... )`: a command of its own. */
14  | { t: 'sub'; src: string }
15  /** An expansion nobody can know ahead (`$1`, `$((n+1))`, `${x:-y}`). */
16  | { t: 'opaque' }
17
18export type RawWord = { parts: Part[]; start: number; end: number }
19
20export type Redir = {
21  op: string
22  fd?: string
23  /** The file it names; absent for a here-doc or a descriptor copy (`2>&1`). */
24  target?: RawWord
25  /** For a here-doc: its delimiter was quoted, so its body is plain data (no `$(...)` runs). */
26  isQuotedDoc?: boolean
27}
28
29export type Simple = {
30  kind: 'simple'
31  words: RawWord[]
32  redirs: Redir[]
33  start: number
34  end: number
35}
36
37export type Group = {
38  kind: 'group'
39  /** `( ... )` runs in a subshell; `{ ...; }` does not, so its `cd` sticks. */
40  subshell: boolean
41  body: Seq
42  redirs: Redir[]
43  start: number
44  end: number
45}
46
47/** A command and the operator after it: `&&`, `||`, `;`, `|`, `&`, or ``. */
48export type Item = (Simple | Group) & { op: string }
49export type Seq = Item[]
50
51const BLANK = new Set([' ', '\t', '\r'])
52const ENDS_WORD = new Set([' ', '\t', '\r', '\n', ';', '&', '|', '(', ')', '<', '>'])
53const REDIRECT = /^(\d*)(&>>|&>|>>|>\||>&|<<<|<<-|<<|<>|<&|>|<)/
54
55export function parse(src: string): Seq {
56  return new Parser(src).seq(false)
57}
58
59class Parser {
60  private i = 0
61  private heredocs: { delim: string; strip: boolean }[] = []
62
63  constructor(private readonly src: string) {}
64
65  seq(close: ')' | '}' | false): Seq {
66    const items: Seq = []
67    while (true) {
68      this.blank()
69      if (this.i >= this.src.length) return items
70      const c = this.src[this.i]
71      if (c === '\n') {
72        this.newline()
73        continue
74      }
75      if (c === ')') {
76        this.i++
77        if (close === ')') return items
78        continue
79      }
80      if (c === ';' || c === '&' || c === '|') {
81        this.i++
82        continue
83      }
84      if (close === '}' && this.atWord('}')) {
85        this.i++
86        return items
87      }
88      const node = this.command()
89      this.blank()
90      items.push({ ...node, op: this.operator() })
91    }
92  }
93
94  private command(): Simple | Group {
95    const start = this.i
96    if (this.src.startsWith('((', this.i)) {
97      // Arithmetic `(( n > 2 ))`: its `>` compares, it redirects nothing.
98      this.i = this.balanced(this.i)
99      return { kind: 'simple', words: [], redirs: [], start, end: this.i }
100    }
101    if (this.src[this.i] === '(') {
102      this.i++
103      const body = this.seq(')')
104      return { kind: 'group', subshell: true, body, redirs: this.redirects(), start, end: this.i }
105    }
106    if (this.atWord('{')) {
107      this.i++
108      const body = this.seq('}')
109      return { kind: 'group', subshell: false, body, redirs: this.redirects(), start, end: this.i }
110    }
111    return this.simple()
112  }
113
114  private simple(): Simple {
115    const start = this.i
116    const words: RawWord[] = []
117    const redirs: Redir[] = []
118    while (true) {
119      this.blank()
120      const c = this.src[this.i]
121      if (c === undefined || c === '\n' || c === ';' || c === '|' || c === ')' || c === '(') break
122      if (c === '&' && this.src[this.i + 1] !== '>') break
123      if ((c === '<' || c === '>') && this.src[this.i + 1] === '(') {
124        words.push(this.processSub())
125        continue
126      }
127      const redir = this.redirect()
128      if (redir) {
129        redirs.push(redir)
130        continue
131      }
132      const before = this.i
133      const word = this.word()
134      if (this.i === before) {
135        this.i++
136        continue
137      }
138      words.push(word)
139    }
140    return { kind: 'simple', words, redirs, start, end: this.i }
141  }
142
143  /** Redirects after a group's closing paren or brace. */
144  private redirects(): Redir[] {
145    const out: Redir[] = []
146    while (true) {
147      this.blank()
148      const redir = this.redirect()
149      if (!redir) return out
150      out.push(redir)
151    }
152  }
153
154  private redirect(): Redir | undefined {
155    const m = REDIRECT.exec(this.src.slice(this.i, this.i + 8))
156    if (!m) return undefined
157    const fd = m[1] === '' ? undefined : m[1]
158    const op = m[2] ?? ''
159    this.i += m[0].length
160    this.blank()
161    if (op === '<<' || op === '<<-') {
162      const delim = this.word()
163      this.heredocs.push({ delim: literal(delim), strip: op === '<<-' })
164      return { op, fd, isQuotedDoc: delim.parts.some(p => p.t === 'lit' && p.quoted) }
165    }
166    const target = this.word()
167    if ((op === '>&' || op === '<&') && /^(\d+|-)$/.test(literal(target))) return { op, fd }
168    return { op, fd, target }
169  }
170
171  private operator(): string {
172    const two = this.src.slice(this.i, this.i + 2)
173    if (two === '&&' || two === '||' || two === '|&' || two === ';;') {
174      this.i += 2
175      return two === ';;' ? ';' : two
176    }
177    const c = this.src[this.i]
178    if (c === ';' || c === '|' || c === '&') {
179      this.i++
180      return c
181    }
182    if (c === '\n') {
183      this.newline()
184      return ';'
185    }
186    return ''
187  }
188
189  /** Is the next word exactly `w`, unquoted and standing alone? */
190  private atWord(w: string): boolean {
191    if (!this.src.startsWith(w, this.i)) return false
192    const after = this.src[this.i + w.length]
193    return after === undefined || ENDS_WORD.has(after)
194  }
195
196  /** Spaces, tabs, line continuations and comments; never a newline. */
197  private blank(): void {
198    while (this.i < this.src.length) {
199      const c = this.src[this.i]
200      if (c !== undefined && BLANK.has(c)) this.i++
201      else if (c === '\\' && this.src[this.i + 1] === '\n') this.i += 2
202      else if (c === '#') {
203        while (this.i < this.src.length && this.src[this.i] !== '\n') this.i++
204      } else return
205    }
206  }
207
208  /** Consumes a newline, then the bodies of any here-docs opened on its line. */
209  private newline(): void {
210    this.i++
211    for (const doc of this.heredocs) {
212      while (this.i < this.src.length) {
213        const cut = this.src.indexOf('\n', this.i)
214        const lineEnd = cut === -1 ? this.src.length : cut
215        const line = this.src.slice(this.i, lineEnd)
216        this.i = lineEnd + 1
217        if ((doc.strip ? line.replace(/^\t+/, '') : line) === doc.delim) break
218      }
219    }
220    this.heredocs = []
221    if (this.i > this.src.length) this.i = this.src.length
222  }
223
224  private word(): RawWord {
225    const start = this.i
226    const parts: Part[] = []
227    let lit = ''
228    const flush = () => {
229      if (lit !== '') parts.push({ t: 'lit', s: lit, quoted: false })
230      lit = ''
231    }
232    while (this.i < this.src.length) {
233      const c = this.src[this.i] ?? ''
234      if (ENDS_WORD.has(c)) break
235      if (c === '\\') {
236        const next = this.src[this.i + 1]
237        this.i += 2
238        if (next === undefined || next === '\n') continue
239        flush()
240        parts.push({ t: 'lit', s: next, quoted: true })
241      } else if (c === "'") {
242        flush()
243        const close = this.src.indexOf("'", this.i + 1)
244        const end = close === -1 ? this.src.length : close
245        parts.push({ t: 'lit', s: this.src.slice(this.i + 1, end), quoted: true })
246        this.i = end + 1
247      } else if (c === '"') {
248        flush()
249        this.i++
250        this.doubleQuoted(parts)
251      } else if (c === '$') {
252        flush()
253        this.dollar(parts, false)
254      } else if (c === '`') {
255        flush()
256        parts.push({ t: 'sub', src: this.backtick() })
257      } else {
258        lit += c
259        this.i++
260      }
261    }
262    flush()
263    if (this.i > this.src.length) this.i = this.src.length
264    return { parts, start, end: this.i }
265  }
266
267  private doubleQuoted(parts: Part[]): void {
268    let lit = ''
269    const flush = () => {
270      if (lit !== '') parts.push({ t: 'lit', s: lit, quoted: true })
271      lit = ''
272    }
273    while (this.i < this.src.length) {
274      const c = this.src[this.i] ?? ''
275      if (c === '"') {
276        this.i++
277        break
278      }
279      if (c === '\\') {
280        const next = this.src[this.i + 1] ?? ''
281        if (next === '\n') this.i += 2
282        else if ('$`"\\'.includes(next) && next !== '') {
283          lit += next
284          this.i += 2
285        } else {
286          lit += c
287          this.i++
288        }
289      } else if (c === '$') {
290        flush()
291        this.dollar(parts, true)
292      } else if (c === '`') {
293        flush()
294        parts.push({ t: 'sub', src: this.backtick() })
295      } else {
296        lit += c
297        this.i++
298      }
299    }
300    flush()
301  }
302
303  private dollar(parts: Part[], inQuotes: boolean): void {
304    const next = this.src[this.i + 1] ?? ''
305    if (next === '(') {
306      if (this.src[this.i + 2] === '(') {
307        this.i = this.balanced(this.i + 1)
308        parts.push({ t: 'opaque' })
309        return
310      }
311      const end = this.balanced(this.i + 1)
312      parts.push({ t: 'sub', src: this.src.slice(this.i + 2, Math.max(this.i + 2, end - 1)) })
313      this.i = end
314    } else if (next === '{') {
315      const close = this.src.indexOf('}', this.i + 2)
316      const end = close === -1 ? this.src.length : close
317      const inner = this.src.slice(this.i + 2, end)
318      parts.push(/^[A-Za-z_][A-Za-z0-9_]*$/.test(inner) ? { t: 'var', name: inner, quoted: inQuotes } : { t: 'opaque' })
319      this.i = end + 1
320    } else if (/[A-Za-z_]/.test(next)) {
321      const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.i + 1)) ?? ['']
322      parts.push({ t: 'var', name: m[0], quoted: inQuotes })
323      this.i += 1 + m[0].length
324    } else if (/[0-9@*#?$!-]/.test(next) && next !== '') {
325      parts.push({ t: 'opaque' })
326      this.i += 2
327    } else if (next === "'" && !inQuotes) {
328      // $'...': ANSI-C quoting; the common escapes are enough here.
329      let s = ''
330      let j = this.i + 2
331      while (j < this.src.length && this.src[j] !== "'") {
332        if (this.src[j] === '\\' && j + 1 < this.src.length) {
333          const e = this.src[j + 1] ?? ''
334          s += e === 'n' ? '\n' : e === 't' ? '\t' : e
335          j += 2
336        } else s += this.src[j++]
337      }
338      parts.push({ t: 'lit', s, quoted: true })
339      this.i = j + 1
340    } else if (next === '"' && !inQuotes) {
341      this.i += 2
342      this.doubleQuoted(parts)
343    } else {
344      parts.push({ t: 'lit', s: '$', quoted: inQuotes })
345      this.i++
346    }
347  }
348
349  /** From an opening paren at `open`, the index just past its partner. */
350  private balanced(open: number): number {
351    let depth = 0
352    let j = open
353    while (j < this.src.length) {
354      const c = this.src[j]
355      if (c === '\\') j += 2
356      else if (c === "'") {
357        const close = this.src.indexOf("'", j + 1)
358        j = close === -1 ? this.src.length : close + 1
359      } else if (c === '"') {
360        j++
361        while (j < this.src.length && this.src[j] !== '"') j += this.src[j] === '\\' ? 2 : 1
362        j++
363      } else if (c === '(') {
364        depth++
365        j++
366      } else if (c === ')') {
367        depth--
368        j++
369        if (depth === 0) return j
370      } else j++
371    }
372    return this.src.length
373  }
374
375  private backtick(): string {
376    let j = this.i + 1
377    let s = ''
378    while (j < this.src.length && this.src[j] !== '`') {
379      if (this.src[j] === '\\' && j + 1 < this.src.length) {
380        s += this.src[j + 1]
381        j += 2
382      } else s += this.src[j++]
383    }
384    this.i = j + 1
385    return s
386  }
387
388  /** `<( ... )` or `>( ... )`: a command whose output stands in as a file. */
389  private processSub(): RawWord {
390    const start = this.i
391    const end = this.balanced(this.i + 1)
392    const src = this.src.slice(this.i + 2, Math.max(this.i + 2, end - 1))
393    this.i = end
394    return { parts: [{ t: 'sub', src }], start, end }
395  }
396}
397
398/** A word's text when it holds no expansion; quotes removed. */
399export function literal(w: RawWord): string {
400  return w.parts.map(p => (p.t === 'lit' ? p.s : '')).join('')
401}
402
types/index.d.ts 63 lines
1// The values last-look keeps in $.state: the commands it holds right now and
2// the measured report each one shows. Plain JSON data, drawn by the pane and
3// the band above the prompt.
4
5export type LastLookTone = 'error' | 'warning' | 'subtle' | 'normal'
6
7export type LastLookRow = { text: string; tone?: LastLookTone }
8
9/** What one destructive part of a command would do, as measured. */
10export type LastLookItem = {
11  /** The part's own text, e.g. `rm -rf build`. */
12  title: string
13  /** Where it runs, relative to the session's directory, when not there. */
14  where?: string
15  /** `nothing`: measured and found nothing (still held); `unknown`: could not measure. */
16  effect: 'nothing' | 'some' | 'unknown'
17  summary: string
18  summaryTone: LastLookTone
19  /** Detail rows: files, commits, git notes; at most a screenful are kept. */
20  rows: LastLookRow[]
21  /** How many more rows there were than `rows` keeps. */
22  moreRows: number
23  files: number
24  bytes: number
25  /** Files with work git cannot bring back (uncommitted or never added). */
26  unsaved: number
27  /** Commits that would leave every branch. */
28  commits: number
29  /** Every target is a plain literal path whose real path lies inside the alwaysAllow folders. */
30  isAllowed: boolean
31  /**
32   * Not destructive after all, decided only where the classifier cannot know
33   * alone: a redirect to a plain path that does not exist yet (or is a
34   * folder), and `git checkout <name>` that git confirms is a branch. An empty
35   * or failed measurement never sets it: a destructive command always holds.
36   */
37  isHarmless: boolean
38  /** It deletes your home folder, `/`, or a top-level system folder. */
39  isCatastrophic: boolean
40}
41
42export type LastLookReport = {
43  command: string
44  headline: string
45  headlineTone: LastLookTone
46  items: LastLookItem[]
47}
48
49export type LastLookHold = {
50  /** The held call's tool_use_id. */
51  id: string
52  /** Where it is shown: the pane, or the band above the prompt. */
53  mode: 'pane' | 'band'
54  report: LastLookReport
55  isSubagent: boolean
56}
57
58declare module 'claude-code' {
59  interface PluginState {
60    'last-look': { holds: LastLookHold[] }
61  }
62}
63