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

<img src="../../brand/pixel-icons/svg/last-look.svg" width="64" height="64" alt="">
Holds destructive commands until you approve, and shows what they would destroy.
/plugin install last-look --marketplace modmyclaude/claude-code-mods
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.
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.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.
| Command | Measured 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, -ffdx | git 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-changes | uncommitted 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 / -f | held 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:
> 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;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.)Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
| Setting | Default | What it does |
|---|---|---|
alwaysAllow | dist,build,.next,coverage,node_modules/.cache | Folders 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. |
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.HOME../clean.sh, npm run clean, make clean, echo ... | bash, bash <<EOF, bash script.sh);$(echo rm) -rf, "$RM" -rf, eval $CMD), shell and git aliases, flags hidden in variables (rm $FLAGS dir);python -c, node -e, perl -e, rsync --delete, grm, busybox rm, parallel rm, ssh host rm, docker run ... rm);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.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".** patterns, and brace expressions past 256 words, are held as "could not measure" rather than guessed at.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./ and top-level system folders are never walked: they are held with a plain warning instead.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.find and du are usually missing, so commands are held as "could not measure".This mod was built from prompts/last-look.md.
More mods: the full catalogue.
hooks/register.tsx 234 lines1// 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}
234hooks/classify.ts 724 lines1// 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 }
724hooks/measure.ts 908 lines1// 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}
908hooks/paths.ts 91 lines1// 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}
91hooks/text.ts 19 lines1// 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}
19hooks/view.tsx 156 lines1// 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}
156hooks/shell.ts 402 lines1// 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}
402types/index.d.ts 63 lines1// 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