A do-over for risky shell commands: it saves your files first, and /mulligan puts them back.

<img src="../../brand/pixel-icons/svg/mulligan.svg" width="64" height="64" alt="">
A do-over for risky shell commands: it saves your files first, and /mulligan puts them back.
mulligan: saved a do-over before "rm -rf dist" (dist) · /mulligan restore 3
3 do-overs for this project
#3 rm -rf dist [Restore] [Diff]
dist · 4.2 MB · just now
#2 git reset --hard [Restore] [Diff]
tracked changes · 2h ago
/plugin install mulligan --marketplace modmyclaude/claude-code-mods
Nothing to set up. Claude Code's /rewind puts back the files Claude edited with its own tools; it does not undo what a shell command did. Mulligan covers that:
rm -r, find ... -delete, xargs rm and a redirect over a file (> notes.md): the paths they name.git reset --hard, git checkout or git restore over changes: your tracked files as they are, as a git commit object (git stash create, which moves no branch and leaves the stash list alone).git clean: the untracked files git lists (not the ignored ones).rm -rf "$OUT", rm -rf **): the whole project, without .git and node_modules./mulligan lists this project's snapshots, newest first, with what each holds and when.The same commands last-look holds take a snapshot, and nothing else: a listing, a test run, a single-file rm, a push or a branch delete never does.
| Command | What it does |
|---|---|
/mulligan | The list, in a pane |
/mulligan restore <n> | Snapshot n, waiting on your confirm press |
/mulligan diff <n> | What differs now from snapshot n |
/mulligan drop <n> | Forget snapshot n and empty its archive (only when you type it) |
"Only when you type it" means typed by you in the terminal, the desktop app or Remote Control. A program you run that drives Claude Code through its SDK counts as you too, since the desktop app sends that way; another mod, a peer session or Claude running the command is refused.
Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
/mulligan says it is off. /mulligan restore <n> still puts back a do-over saved while it was on, after the same confirm: those are your files, and turning it on just to reach them would start saving again, which can reuse the oldest slot. A flip never deletes a do-over. /config or the modmyclaude hub flips it, live.| between several) for commands that should save the whole project first, such as prisma migrate|npm uninstall.rm -rf (dist|build).~/.claude/mulligan/<project key>/ (or under CLAUDE_CONFIG_DIR when set): ten slots per project, reused in turn, never more. The key is a hash of the project's path. Each archive holds copies of your project's files, so anything in them (a .env among them) sits there too, in your home folder only.git rev-parse, git stash create, git ls-files, git diff and, on a restore you confirmed, git restore --overlay, all with git's optional locks off, so it never takes the index lock under Claude. tar to make, list and unpack archives, and mkdir and mv inside its own folder. It never runs rm, never moves a branch, never touches .git beyond the objects git stash create adds./mulligan answers in a pane and toasts.git clean -x; the line says so.rm -rf can delete most of what it named before it stops.git restore).This mod was built from the Mulligan prompt.
More mods: the full catalogue.
hooks/register.tsx 535 lines1// mulligan: a do-over for shell commands. Before Claude runs a Bash command
2// that destroys files (the same classifier last-look uses: rm -r, find
3// -delete, xargs rm, git clean, git reset --hard, git checkout or restore over
4// changes, git worktree remove, a redirect over a file), it saves what that
5// command can destroy: the paths it names, or git's tracked changes as a
6// commit object (`git stash create`, which moves no ref), or the untracked
7// files, or the whole project when the targets are only known as it runs.
8// /mulligan lists them and puts one back after a confirm press.
9//
10// Saved before the tool runs, into a pending archive; kept only once the
11// command really ran (a hold beneath that refuses it leaves no snapshot). Ten
12// per project, in slots under ~/.claude/mulligan/<project key>/, reused in
13// turn: nothing is ever deleted with rm. A restore writes files back over what
14// is there and deletes nothing newer, then tells Claude to re-read.
15
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import type { MulliganSnapshot } from '../types'
20import { classify } from './classify'
21import { agoWords, clean, isEmpty, oneLine, planFor, projectKey, restoreNote, scopeWords, shown, sizeWords, treePlan } from './plan'
22import type { Plan } from './plan'
23
24const SNAPSHOTS = { plugin: 'mulligan', key: 'snapshots' } as const
25const BAND = { plugin: 'mulligan', key: 'band' } as const
26const VIEW = { plugin: 'mulligan', key: 'view' } as const
27const snapshots = atom(SNAPSHOTS, [])
28const band = atom(BAND, null)
29const view = atom(VIEW, { kind: 'list', confirm: 0 })
30
31/** Snapshots kept per project, and so the archive slots. */
32export const KEEP = 10
33const BAND_MS = 8_000
34const PANE = 'mulligan'
35const TAR_MS = 20_000
36const DIFF_FILES = 50
37/** Origins that are the person: typed, the phone bridge, the desktop app's box. */
38const PERSON = new Set(['composer', 'bridge', 'sdk'])
39/** git never takes the index lock for us, never prompts, and speaks C. */
40const ENV = { LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
41const SHA = /^[0-9a-f]{40,64}$/
42
43export type Settings = { always: RegExp | undefined; ignore: RegExp | undefined; capBytes: number }
44
45/** The person's settings: patterns that do not compile are left out, the cap clamped. */
46export function readSettings(options: Record<string, unknown>): Settings {
47 const pattern = (v: unknown) => {
48 if (typeof v !== 'string' || v.trim() === '' || v.length > 500) return undefined
49 try {
50 return new RegExp(v, 'i')
51 } catch {
52 return undefined
53 }
54 }
55 const mb = typeof options.capMB === 'number' && Number.isFinite(options.capMB) ? Math.min(5_000, Math.max(1, options.capMB)) : 200
56 return { always: pattern(options.always), ignore: pattern(options.ignore), capBytes: Math.round(mb * 1_000_000) }
57}
58
59type Index = { v: 1; next: number; snapshots: MulliganSnapshot[] }
60type Where = { root: string; cwd: string; home: string | undefined; base: string }
61type Pending = { command: string; plan: Plan; sha?: string; archive?: string; bytes: number; where: Where }
62type Prepared = { pending?: Pending; skip?: { text: string; tone: 'subtle' | 'warning' } }
63
64let settings: Settings = readSettings({})
65
66// The switch (`enabled`, which /config and the modmyclaude hub flip): off, Mulligan saves
67// nothing before any command and draws no band. The do-overs it already saved stay, and
68// /mulligan restore still puts one back: they are the person's files, and turning the
69// switch on just to reach them would start saving again, which can reuse the oldest slot.
70// The engine reloads the mod with the new value; the config.set hook below is the same
71// switch at once.
72let isOn = true
73const OFF_TEXT = 'Mulligan is off. /mods or /config turns it on.'
74
75/** Index writes one at a time: Claude can run two commands at once. */
76let chain: Promise<unknown> = Promise.resolve()
77
78/** Runs `work` after every index write before it; a failure never blocks the next one. */
79function queued<T>(work: () => Promise<T>): Promise<T> {
80 const done = chain.then(work)
81 chain = done.catch(() => undefined)
82 return done
83}
84let pendingCount = 0
85
86function debug($: EngineInterface, error: unknown): void {
87 $.ui.log(`mulligan: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
88}
89
90function run($: EngineInterface, argv: string[], cwd: string, stdin?: string, timeoutMs = 15_000) {
91 return $.process.run(argv, { cwd, env: ENV, timeoutMs, ...(stdin === undefined ? {} : { stdin }) })
92}
93
94async function whereNow($: EngineInterface): Promise<Where | null> {
95 const [root, cwd, config, home] = await Promise.all([$.session.root(), $.session.cwd(), $.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME')])
96 const dir = config ? config.replace(/\/+$/, '') : home ? `${home.replace(/\/+$/, '')}/.claude` : undefined
97 if (dir === undefined) return null
98 return { root, cwd, home: home || undefined, base: `${dir}/mulligan/${projectKey(root)}` }
99}
100
101async function isGitAt($: EngineInterface, root: string): Promise<boolean> {
102 try {
103 const r = await run($, ['git', 'rev-parse', '--is-inside-work-tree'], root)
104 return r.exitCode === 0 && r.stdout.trim() === 'true'
105 } catch {
106 return false
107 }
108}
109
110/** The project's snapshot list, from its folder; empty when there is none or it does not read. */
111async function readIndex($: EngineInterface, base: string): Promise<Index> {
112 try {
113 const parsed = JSON.parse(await $.fs.read(`${base}/index.json`)) as Partial<Index>
114 if (parsed.v !== 1 || typeof parsed.next !== 'number' || !Array.isArray(parsed.snapshots)) throw new Error('not an index')
115 return { v: 1, next: parsed.next, snapshots: parsed.snapshots.filter(s => typeof s?.n === 'number').slice(-KEEP) }
116 } catch {
117 return { v: 1, next: 1, snapshots: [] }
118 }
119}
120
121async function sizeOf($: EngineInterface, base: string, name: string): Promise<number> {
122 try {
123 return (await $.fs.list(base)).find(entry => entry.name === name)?.size ?? 0
124 } catch {
125 return 0
126 }
127}
128
129/** Empties one of our own archives: the API has no delete, and an empty file costs nothing. */
130async function emptyArchive($: EngineInterface, base: string, name: string): Promise<void> {
131 if (!/^(?:slot-\d|pending-\d+)\.tar$/.test(name)) return
132 await $.fs.write(`${base}/${name}`, '').catch(() => undefined)
133}
134
135/**
136 * Before the command runs: what it can destroy, saved into a pending archive
137 * (and a git object). A skip says why nothing was saved, when that is news.
138 */
139async function prepare($: EngineInterface, shellCommand: string): Promise<Prepared> {
140 if (settings.ignore?.test(shellCommand)) return {}
141 const where = await whereNow($)
142 if (where === null) return {}
143 const isGit = await isGitAt($, where.root)
144 let plan = planFor(classify(shellCommand, where.home === undefined ? {} : { home: where.home }), where.cwd, where.root, isGit)
145 if (settings.always?.test(shellCommand)) plan = { ...treePlan(isGit), outside: plan?.outside ?? [], notes: plan?.notes ?? [] }
146 if (plan === null) return {}
147 const cmd = oneLine(clean(shellCommand), 48)
148 if (isEmpty(plan.scope)) {
149 return plan.outside.length > 0 ? { skip: { text: `mulligan: no do-over for "${cmd}": ${clean(plan.outside[0]!)} is outside the project.`, tone: 'subtle' } } : {}
150 }
151 const { root, base } = where
152 let sha: string | undefined
153 if (plan.scope.isTracked) {
154 const made = await run($, ['git', 'stash', 'create'], root).catch(() => undefined)
155 sha = made?.exitCode === 0 ? made.stdout.trim() : undefined
156 if (!sha) {
157 // Nothing changed since the last commit: HEAD is the tracked files' state.
158 const head = await run($, ['git', 'rev-parse', '--verify', 'HEAD'], root).catch(() => undefined)
159 sha = head?.exitCode === 0 ? head.stdout.trim() : undefined
160 }
161 if (sha !== undefined && !SHA.test(sha)) sha = undefined
162 }
163
164 // The files to copy: the whole tree without git; else the untracked ones and the named paths that exist.
165 const isWholeTree = plan.scope.isTree && !plan.scope.isTracked
166 const files: string[] = []
167 if (!isWholeTree) {
168 if (plan.scope.isUntracked) {
169 const listed = await run($, ['git', 'ls-files', '--others', '--exclude-standard', '-z', '--', '.'], root).catch(() => undefined)
170 if (listed?.exitCode === 0) files.push(...listed.stdout.split('\0').filter(name => name !== ''))
171 }
172 for (const path of plan.scope.paths) {
173 if (await $.fs.exists(path).catch(() => false)) files.push(shown(path, root))
174 }
175 }
176 let archive: string | undefined
177 let bytes = 0
178 if (isWholeTree || files.length > 0) {
179 const name = `pending-${++pendingCount}.tar`
180 await run($, ['mkdir', '-p', base], root)
181 const argv = isWholeTree
182 ? ['tar', '-cf', `${base}/${name}`, '--exclude=.git', '--exclude=node_modules', '.']
183 : ['tar', '-cf', `${base}/${name}`, '--exclude=.git', '--null', '-T', '-']
184 let ok = false
185 try {
186 const made = await run($, argv, root, isWholeTree ? undefined : `${files.join('\0')}\0`, TAR_MS)
187 ok = made.exitCode === 0
188 } catch {
189 ok = false // too slow: too big
190 }
191 bytes = ok ? await sizeOf($, base, name) : 0
192 if (!ok || bytes > settings.capBytes) {
193 await emptyArchive($, base, name)
194 const why = ok ? `too big to save (${sizeWords(bytes)})` : 'could not be saved'
195 if (sha === undefined) return { skip: { text: `mulligan: ${why}; "${cmd}" runs without a do-over.`, tone: 'warning' } }
196 plan = { ...plan, notes: [...plan.notes, `the files it names were ${why}`] }
197 bytes = 0
198 } else {
199 archive = name
200 }
201 }
202 if (sha === undefined && archive === undefined) return {} // nothing there to lose (a redirect to a new file)
203 return { pending: { command: shellCommand, plan, ...(sha === undefined ? {} : { sha }), ...(archive === undefined ? {} : { archive }), bytes, where } }
204}
205
206/** After the command ran: the pending snapshot takes its slot, the oldest gives way. */
207async function commit($: EngineInterface, p: Pending): Promise<MulliganSnapshot> {
208 const { base, root } = p.where
209 const index = await readIndex($, base)
210 const n = index.next
211 const slot = `slot-${(n - 1) % KEEP}.tar`
212 if (p.archive !== undefined) {
213 const moved = await run($, ['mv', '-f', `${base}/${p.archive}`, `${base}/${slot}`], root)
214 if (moved.exitCode !== 0) throw new Error(`could not keep the snapshot: ${moved.stderr.trim()}`)
215 }
216 const snap: MulliganSnapshot = {
217 n,
218 at: await $.clock.now(),
219 command: oneLine(clean(p.command), 160),
220 scope: scopeWords(p.plan.scope, root),
221 ...(p.sha === undefined ? {} : { sha: p.sha }),
222 ...(p.archive === undefined ? {} : { archive: slot }),
223 bytes: p.bytes,
224 }
225 const kept = [...index.snapshots, snap].slice(-KEEP)
226 for (const old of index.snapshots) {
227 if (!kept.includes(old) && old.archive !== undefined && old.archive !== snap.archive) await emptyArchive($, base, old.archive)
228 }
229 await $.fs.write(`${base}/index.json`, JSON.stringify({ v: 1, next: n + 1, snapshots: kept } satisfies Index))
230 await update($, snapshots, () => kept)
231 return snap
232}
233
234async function showBand($: EngineInterface, text: string, tone: 'subtle' | 'warning'): Promise<void> {
235 const until = (await $.clock.now()) + BAND_MS
236 await update($, band, () => ({ text, tone, until }))
237 $.clock.after(BAND_MS, () => {
238 void update($, band, b => (b !== null && b.until <= until ? null : b)).catch(() => undefined)
239 })
240}
241
242async function findSnapshot($: EngineInterface, n: number): Promise<{ where: Where; snap: MulliganSnapshot } | null> {
243 const where = await whereNow($)
244 if (where === null) return null
245 const snap = (await readIndex($, where.base)).snapshots.find(s => s.n === n)
246 return snap === undefined ? null : { where, snap }
247}
248
249/** Puts snapshot n back: files written over what is there, nothing newer deleted; Claude is told. */
250async function restore($: EngineInterface, n: number): Promise<string> {
251 const found = await findSnapshot($, n)
252 if (found === null) return `mulligan: there is no snapshot ${n} for this project.`
253 const { where, snap } = found
254 const problems: string[] = []
255 if (snap.sha !== undefined && SHA.test(snap.sha)) {
256 const r = await run($, ['git', 'restore', '--overlay', `--source=${snap.sha}`, '--worktree', '--', '.'], where.root).catch(() => undefined)
257 if (r?.exitCode !== 0) problems.push(`tracked files (${oneLine(r?.stderr ?? 'git did not answer', 80)})`)
258 }
259 if (snap.archive !== undefined) {
260 if ((await sizeOf($, where.base, snap.archive)) === 0) problems.push('its files (the archive is gone)')
261 else {
262 const r = await run($, ['tar', '-xf', `${where.base}/${snap.archive}`], where.root, undefined, 60_000).catch(() => undefined)
263 if (r?.exitCode !== 0) problems.push(`files (${oneLine(r?.stderr ?? 'tar did not finish', 80)})`)
264 }
265 }
266 try {
267 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: restoreNote(snap.command, n) }] } })
268 } catch (error) {
269 debug($, error)
270 }
271 return problems.length === 0 ? `mulligan: restored #${n} (${snap.scope}). Claude was told to re-read files.` : `mulligan: #${n} restored in part; not put back: ${problems.join('; ')}.`
272}
273
274/** What differs now from snapshot n: tracked files through git, copied files one by one. */
275async function diffLines($: EngineInterface, n: number): Promise<string[]> {
276 const found = await findSnapshot($, n)
277 if (found === null) return [`There is no snapshot ${n} for this project.`]
278 const { where, snap } = found
279 const lines: string[] = []
280 if (snap.sha !== undefined && SHA.test(snap.sha)) {
281 const r = await run($, ['git', 'diff', '--name-status', snap.sha, '--', '.'], where.root).catch(() => undefined)
282 const words: Record<string, string> = { M: 'changed', D: 'deleted', A: 'new since', T: 'changed' }
283 for (const line of (r?.exitCode === 0 ? r.stdout : '').split('\n').filter(Boolean).slice(0, 100)) {
284 const [status = '', path = ''] = line.split('\t')
285 lines.push(`${words[status[0] ?? ''] ?? 'differs'}: ${path}`)
286 }
287 }
288 if (snap.archive !== undefined && (await sizeOf($, where.base, snap.archive)) > 0) {
289 const file = `${where.base}/${snap.archive}`
290 const listed = await run($, ['tar', '-tf', file], where.root).catch(() => undefined)
291 const entries = (listed?.exitCode === 0 ? listed.stdout : '').split('\n').filter(name => name !== '' && !name.endsWith('/'))
292 for (const entry of entries.slice(0, DIFF_FILES)) {
293 const path = `${where.root}/${entry.replace(/^\.\//, '')}`
294 if (!(await $.fs.exists(path).catch(() => false))) {
295 lines.push(`deleted: ${entry.replace(/^\.\//, '')}`)
296 continue
297 }
298 const then = await run($, ['tar', '-xOf', file, '--', entry], where.root).catch(() => undefined)
299 const now = await $.fs.read(path).catch(() => undefined)
300 if (then?.exitCode === 0 && now !== undefined && then.stdout !== now) lines.push(`changed: ${entry.replace(/^\.\//, '')}`)
301 }
302 if (entries.length > DIFF_FILES) lines.push(`and ${entries.length - DIFF_FILES} more files not compared`)
303 }
304 return lines.length > 0 ? lines : ['Nothing differs: the files it holds are as they were.']
305}
306
307async function openPane($: EngineInterface): Promise<boolean> {
308 try {
309 return (await $.ui.open({ id: PANE, title: 'mulligan', focus: true, closeOnEscape: true })).isPlaced
310 } catch {
311 return false
312 }
313}
314
315/** The list as the project's folder has it (another session in this project may have added one). */
316async function syncList($: EngineInterface): Promise<void> {
317 const where = await whereNow($)
318 if (where === null) return
319 const list = (await readIndex($, where.base)).snapshots
320 await update($, snapshots, () => list)
321}
322
323/** /mulligan: answers in the pane and toasts, never text Claude reads. */
324async function command($: EngineInterface, args: string, origin: string): Promise<void> {
325 const [verb = '', arg = ''] = args.trim().split(/\s+/)
326 const action = verb.toLowerCase()
327 const n = Number(arg)
328 // Off, a restore is the one word that still runs.
329 if (!isOn && action !== 'restore') return $.ui.toast(OFF_TEXT)
330 await syncList($)
331 if (action === '' || action === 'list') {
332 await update($, view, () => ({ kind: 'list' as const, confirm: 0 }))
333 if (!(await openPane($))) $.ui.toast(`mulligan: ${(await read($, snapshots)).length} do-overs for this project; the list draws in a pane, which only the terminal and the desktop app have.`)
334 return
335 }
336 if (!Number.isInteger(n) || n < 1 || !(await read($, snapshots)).some(s => s.n === n)) {
337 return $.ui.toast(`mulligan: there is no snapshot ${oneLine(clean(arg), 12) || '(none given)'} for this project.${isOn ? ' /mulligan lists them.' : ''}`)
338 }
339 if (action === 'restore') {
340 await update($, view, () => ({ kind: 'list' as const, confirm: n }))
341 if (!(await openPane($))) $.ui.toast('mulligan: a restore waits on a confirm in a pane, which only the terminal and the desktop app have.')
342 return
343 }
344 if (action === 'diff') {
345 const lines = await diffLines($, n)
346 await update($, view, () => ({ kind: 'diff' as const, n, lines }))
347 if (!(await openPane($))) $.ui.toast(`mulligan: #${n} differs in ${lines.length} place${lines.length === 1 ? '' : 's'}; the list draws in a pane.`)
348 return
349 }
350 if (action === 'drop') {
351 if (!PERSON.has(origin)) return $.ui.toast('mulligan: /mulligan drop runs only when you type it yourself.')
352 const where = await whereNow($)
353 if (where === null) return
354 await queued(async () => {
355 const index = await readIndex($, where.base)
356 const gone = index.snapshots.find(s => s.n === n)
357 if (gone?.archive !== undefined) await emptyArchive($, where.base, gone.archive)
358 const kept = index.snapshots.filter(s => s.n !== n)
359 await $.fs.write(`${where.base}/index.json`, JSON.stringify({ v: 1, next: index.next, snapshots: kept } satisfies Index))
360 await update($, snapshots, () => kept)
361 })
362 return $.ui.toast(`mulligan: dropped #${n}.`)
363 }
364 $.ui.toast(`mulligan: unknown command "${oneLine(clean(verb), 24)}". /mulligan lists; restore <n>, diff <n>, drop <n>.`)
365}
366
367export const register: Register = (on, options) => {
368 settings = readSettings(options)
369 isOn = options.enabled !== false
370
371 // Never touches a snapshot: the switch only stops new ones and the band.
372 on('config.set', { key: 'mulligan.enabled' }, async ($, e, next) => {
373 const set = await next(e)
374 if (set.deny === undefined && typeof set.value === 'boolean') {
375 isOn = set.value
376 $.ui.invalidate('ui.render')
377 }
378 return set
379 })
380
381 on('session.start', async ($, e, next) => {
382 try {
383 await $.command.register({
384 name: 'mulligan',
385 description: 'List the do-overs saved before destructive commands, and put one back',
386 argumentHint: '[restore <n> | diff <n> | drop <n>]',
387 immediate: true,
388 })
389 } catch (error) {
390 debug($, error)
391 }
392 return next(e)
393 })
394
395 // Saved before the tool runs; kept once it ran. The tool runs exactly once whatever happens here.
396 // Off, no snapshot: the call passes as it came. One taken before a switch mid-command is still
397 // kept (its files are already saved); its band is not drawn while off.
398 on('tool.call', async ($, e, next) => {
399 if (!isOn || e.tool !== 'Bash') return next(e)
400 let prepared: Prepared = {}
401 try {
402 prepared = await prepare($, e.command)
403 } catch (error) {
404 debug($, error)
405 }
406 const ran = await next(e)
407 try {
408 const { pending, skip } = prepared
409 if (ran.deny !== undefined) {
410 // Held or refused beneath: the command never ran, so there is nothing to undo.
411 if (pending?.archive !== undefined) await emptyArchive($, pending.where.base, pending.archive)
412 } else if (pending !== undefined) {
413 const snap = await queued(() => commit($, pending))
414 const cmd = oneLine(clean(e.command), 40)
415 const extra = [...pending.plan.notes, ...pending.plan.outside.map(path => `${clean(path)} is outside the project, not saved`)]
416 await showBand($, `mulligan: saved a do-over before "${cmd}" (${snap.scope}) · /mulligan restore ${snap.n}${extra.length ? ` · ${extra.join('; ')}` : ''}`, extra.length ? 'warning' : 'subtle')
417 } else if (skip !== undefined) {
418 await showBand($, skip.text, skip.tone)
419 }
420 } catch (error) {
421 debug($, error)
422 }
423 return ran
424 })
425
426 // One line under the other bands, for a few seconds after a snapshot or a skip.
427 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
428 if (!isOn) return next(e)
429 const below = await next(e)
430 const b = await read($, band)
431 if (b === null || (await $.clock.now()) >= b.until || (e.surface !== 'terminal' && e.surface !== 'desktop')) return below
432 const { Box, Text } = $.ui.resolve(e)
433 return (
434 <Box flexDirection="column">
435 {below}
436 <Text key="mulligan" dimColor={b.tone === 'subtle'} color={b.tone === 'warning' ? 'warning' : undefined}>
437 {b.text}
438 </Text>
439 </Box>
440 )
441 }).catch(($, e, next) => next(e))
442
443 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
444 const { Box, Button, Text } = $.ui.resolve(e)
445 const list = await read($, snapshots)
446 const v = await read($, view)
447 const now = await $.clock.now()
448 // A restore waits on this press, on or off.
449 const confirm = (s: MulliganSnapshot) => (
450 <Box key="confirm" flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
451 <Text key="ask">{`Put back ${clean(s.scope)} as it was before #${s.n}? Files it holds are written over what is there now; nothing newer is deleted.`}</Text>
452 <Box key="choices" flexDirection="row" gap={2}>
453 <Button
454 key="yes"
455 label="Yes, restore"
456 onPress={async () => {
457 const said = await restore($, s.n)
458 await update($, view, () => ({ kind: 'list' as const, confirm: 0 }))
459 $.ui.toast(said)
460 }}
461 />
462 <Button key="cancel" label="Cancel" autoFocus onPress={() => void update($, view, () => ({ kind: 'list' as const, confirm: 0 }))} />
463 </Box>
464 </Box>
465 )
466 if (!isOn) {
467 const asked = v.kind === 'list' ? list.find(s => s.n === v.confirm) : undefined
468 return (
469 <Box flexDirection="column" paddingX={1}>
470 <Text key="off" dimColor>
471 {OFF_TEXT}
472 </Text>
473 {asked === undefined ? null : confirm(asked)}
474 </Box>
475 )
476 }
477 if (v.kind === 'diff') {
478 return (
479 <Box flexDirection="column" paddingX={1}>
480 <Text key="title" bold>{`#${v.n} against the files now`}</Text>
481 {v.lines.map((line, i) => (
482 <Text key={`diff-${i}`}>{clean(line)}</Text>
483 ))}
484 <Button key="back" label="Back to the list" onPress={() => void update($, view, () => ({ kind: 'list' as const, confirm: 0 }))} />
485 </Box>
486 )
487 }
488 return (
489 <Box flexDirection="column" paddingX={1}>
490 <Text key="title" bold>
491 {list.length === 0 ? 'No do-overs yet for this project' : `${list.length} do-over${list.length === 1 ? '' : 's'} for this project`}
492 </Text>
493 {[...list].reverse().map(s => (
494 <Box key={`snap-${s.n}`} flexDirection="column">
495 <Box key="line" flexDirection="row" gap={1}>
496 <Box key="n" width={4} flexShrink={0}>
497 <Text dimColor>{`#${s.n}`}</Text>
498 </Box>
499 <Box key="command" flexGrow={1} flexShrink={1} minWidth={8}>
500 <Text wrap="truncate-end">{clean(s.command)}</Text>
501 </Box>
502 <Box key="buttons" flexShrink={0} flexDirection="row" gap={1}>
503 <Button key="restore" label="Restore" onPress={() => void update($, view, () => ({ kind: 'list' as const, confirm: s.n }))} />
504 <Button
505 key="diff"
506 label="Diff"
507 onPress={async () => {
508 const lines = await diffLines($, s.n)
509 await update($, view, () => ({ kind: 'diff' as const, n: s.n, lines }))
510 }}
511 />
512 </Box>
513 </Box>
514 <Text key="about" dimColor>{` ${clean(s.scope)}${s.bytes > 0 ? ` · ${sizeWords(s.bytes)}` : ''} · ${agoWords(now - s.at)}`}</Text>
515 {v.confirm === s.n ? confirm(s) : null}
516 </Box>
517 ))}
518 <Text key="note" dimColor>
519 Files in this project only: a database, a remote or anything outside the folder is not covered.
520 </Text>
521 </Box>
522 )
523 })
524
525 on('command.run', { command: 'mulligan' }, async ($, e) => {
526 try {
527 await command($, e.args, e.origin.kind)
528 } catch (error) {
529 debug($, error)
530 $.ui.toast('mulligan: that command failed, and nothing changed.')
531 }
532 return {}
533 })
534}
535hooks/classify.ts 727 lines1// The same file as last-look's hooks/classify.ts, copied so this mod installs on its own.
2// Keep the two in step: a fix to one is a fix to both.
3
4// Pure: given a Bash command string, which parts of it can destroy work?
5//
6// No `$`, no I/O: the hook measures what this finds. The rule of thumb is to
7// catch what the spec names in every spelling a model actually writes (flag
8// order, sudo and env prefixes, quoted paths, `cd x && ...`, subshells,
9// `bash -c`, `$(...)`), and to leave look-alikes alone (`echo "rm -rf"`,
10// `grep -r`, a single-file `rm`).
11
12import { literal, parse } from './shell'
13import type { Item, Part, RawWord, Redir, Seq } from './shell'
14
15/** A word as the shell would hand it to the program, when that can be known. */
16export type Word = {
17 text: string
18 /** False when it holds an expansion nobody can know ahead (`$X`, `$(...)`). */
19 known: boolean
20 /** True when unquoted `*`, `?` or `[` make it a pattern the shell expands. */
21 glob: boolean
22 /** As written in the command. */
23 raw: string
24 /**
25 * A literal path as written: no glob, brace, `$VAR`, `~`, `$(...)` or
26 * backticks. Only such paths may be skipped by alwaysAllow, and only they
27 * can be reported "not found" with confidence.
28 */
29 plain: boolean
30}
31
32/** One `cd` on the way to where a command runs. */
33export type CdStep = {
34 dir: Word
35 /**
36 * True when the command runs even if this `cd` fails (`cd x; rm ...`),
37 * so it then runs where the shell already was.
38 */
39 soft: boolean
40}
41
42export type Kind =
43 | 'rm'
44 | 'find-delete'
45 | 'xargs-rm'
46 | 'git-clean'
47 | 'git-reset-hard'
48 | 'git-discard'
49 | 'git-push-force'
50 | 'git-branch-delete'
51 | 'git-worktree-remove'
52 | 'redirect'
53
54export type GitInfo =
55 /** `forces`: 2 for `-ff`, which also removes nested git repositories. */
56 | { sub: 'clean'; flags: string[]; forces: number; excludes: string[]; paths: Word[] }
57 | { sub: 'reset'; ref?: Word }
58 | {
59 sub: 'discard'
60 /** `all`: every uncommitted change; `maybe-ref`: `git checkout x`, a branch or a path. */
61 mode: 'all' | 'paths' | 'maybe-ref'
62 source?: Word
63 /** True when the files come back from the index, so staged work survives. */
64 fromIndex: boolean
65 paths: Word[]
66 }
67 | { sub: 'push'; remote?: Word; refspecs: Word[]; isWholeRepo: boolean }
68 | { sub: 'branch'; names: Word[] }
69 | { sub: 'worktree'; paths: Word[] }
70
71export type Finding = {
72 kind: Kind
73 /** The command's own text, for the person to read. */
74 label: string
75 /** The `cd`s between the session's directory and where it runs. */
76 cd: CdStep[]
77 /** What it acts on: rm operands, find start points, the redirect's file. */
78 targets: Word[]
79 isSudo: boolean
80 git?: GitInfo
81 /** For find: a read-only argv that lists what it would delete. */
82 findListing?: string[]
83 /** Why it cannot be measured, when that is plain from the text alone. */
84 unknown?: string
85 /**
86 * The whole command is this one simple command and this redirect is all it
87 * does that we see: no `&&`, `||`, `;`, `|`, `&`, newline, subshell,
88 * `$(...)` or unquoted here-doc. Only then can "the target does not exist"
89 * be judged before anything runs.
90 */
91 isSole?: boolean
92}
93
94export type ClassifyEnv = { home?: string }
95
96/** How deep `$(...)`, `bash -c` and `eval` may nest before we stop looking. */
97const MAX_DEPTH = 6
98
99export function classify(command: string, env: ClassifyEnv = {}): Finding[] {
100 const out: Finding[] = []
101 const seq = parse(command)
102 walk(seq, command, [], new Map(), env, out, 0)
103 const only = out[0]
104 if (out.length === 1 && only?.kind === 'redirect' && isSoleCommand(seq)) only.isSole = true
105 return out
106}
107
108/** One simple command, nothing before or after it, nothing running inside it. */
109function isSoleCommand(seq: Seq): boolean {
110 const item = seq[0]
111 if (seq.length !== 1 || !item || item.kind !== 'simple' || (item.op !== '' && item.op !== ';')) return false
112 const words = [...item.words, ...item.redirs.flatMap(r => (r.target ? [r.target] : []))]
113 if (words.some(w => w.parts.some(p => p.t === 'sub'))) return false
114 if (item.redirs.some(r => (r.op === '<<' || r.op === '<<-') && !r.isQuotedDoc)) return false
115 return item.redirs.filter(r => r.op === '>' || r.op === '>|' || r.op === '&>' || r.op === '>&').length === 1
116}
117
118type Vars = Map<string, string | undefined>
119
120function walk(seq: Seq, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): CdStep[] {
121 let steps = cd
122 seq.forEach((item, index) => {
123 const before = seq[index - 1]?.op ?? ''
124 // A pipeline part and a `&` command run in a subshell: their `cd` stays there.
125 const isPiped = item.op === '|' || item.op === '|&' || item.op === '&' || before === '|' || before === '|&'
126 if (item.kind === 'group') {
127 const after = walk(item.body, src, steps, vars, env, out, depth)
128 redirects(item.redirs, item, src, steps, vars, env, out, depth)
129 if (!item.subshell && !isPiped) steps = after
130 return
131 }
132 const next = simple(item, src, steps, vars, env, out, depth)
133 if (!isPiped) steps = next
134 })
135 return steps
136}
137
138const RESERVED = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'esac', '!', 'time', '{', '}'])
139const HEADERS = new Set(['for', 'select', 'case', 'function'])
140const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
141
142/** Classifies one simple command; returns the `cd` steps after it. */
143function simple(item: Item & { kind: 'simple' }, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): CdStep[] {
144 const label = src.slice(item.start, item.end).trim()
145 const words = item.words.flatMap(w => resolveAll(w, src, vars, env))
146 // Commands inside the words run first, wherever the shell is now.
147 for (const w of item.words) subs(w, cd, vars, env, out, depth)
148 // Inside `[[ ... ]]` a `>` compares two strings; it writes no file.
149 const isTest = words[0]?.known === true && words[0].text === '[['
150 if (!isTest) redirects(item.redirs, item, src, cd, vars, env, out, depth)
151
152 let argv = words
153 while (argv[0] && RESERVED.has(argv[0].text) && argv[0].known) argv = argv.slice(1)
154 // `function f { body }`: the body is a command like any other.
155 if (argv[0]?.known && argv[0].text === 'function') {
156 argv = argv.slice(2)
157 while (argv[0]?.known && (argv[0].text === '{' || argv[0].text === '()')) argv = argv.slice(1)
158 }
159 const head = argv[0]
160 if (!head || !head.known) return cd
161 if (HEADERS.has(head.text)) return cd
162
163 // Assignments alone set shell variables; before a command they are its env.
164 const firstCommand = argv.findIndex(w => !isAssignment(w))
165 if (firstCommand === -1 || ['export', 'local', 'declare', 'readonly', 'typeset'].includes(head.text)) {
166 for (const w of argv) {
167 if (!isAssignment(w)) continue
168 const name = w.raw.slice(0, w.raw.indexOf('='))
169 vars.set(name, w.known ? w.text.slice(w.text.indexOf('=') + 1) : undefined)
170 }
171 return cd
172 }
173 argv = argv.slice(firstCommand)
174
175 const peeled = peel(argv)
176 argv = peeled.argv
177 const program = argv[0]
178 if (!program || !program.known) return cd
179 const name = base(program.text)
180 const steps = [...cd, ...peeled.cd]
181 const finding = (kind: Kind, rest: Partial<Finding>): void => {
182 out.push({ kind, label, cd: steps, targets: [], isSudo: peeled.isSudo, ...rest })
183 }
184
185 if (name === 'cd' || name === 'pushd') {
186 const operand = argv.slice(1).find(w => !(w.known && /^(-[LPe@]+|--)$/.test(w.text)))
187 const dir = operand ?? { text: env.home ?? '', known: env.home !== undefined, glob: false, raw: '~', plain: false }
188 if (dir.known && dir.text === '-') return [...cd, { dir: { ...dir, known: false }, soft: false }]
189 return [...cd, { dir, soft: item.op !== '&&' }]
190 }
191 if (name === 'popd') return [...cd, { dir: { text: '', known: false, glob: false, raw: 'popd', plain: false }, soft: false }]
192
193 if (peeled.viaXargs && name === 'rm') {
194 finding('xargs-rm', { unknown: 'it deletes whatever the command before it prints' })
195 return cd
196 }
197 if (SHELLS.has(name) && depth < MAX_DEPTH) {
198 const script = shellScript(argv)
199 if (script?.known) walk(parse(script.text), script.text, steps, new Map(vars), env, out, depth + 1)
200 return cd
201 }
202 if (name === 'eval' && depth < MAX_DEPTH) {
203 const text = argv.slice(1).map(w => w.text).join(' ')
204 if (argv.slice(1).every(w => w.known)) walk(parse(text), text, steps, new Map(vars), env, out, depth + 1)
205 return cd
206 }
207 if (name === 'rm') {
208 const rm = parseRm(argv)
209 if (rm.isRecursive && rm.operands.length > 0) finding('rm', { targets: rm.operands })
210 } else if (name === 'find') {
211 const found = parseFind(argv)
212 if (found) finding('find-delete', found)
213 } else if (name === 'git') {
214 const git = parseGit(argv, peeled.viaXargs)
215 if (git) {
216 const unknown = peeled.viaXargs ? 'more arguments come from the command before it' : git.finding.unknown
217 out.push({ label, cd: [...steps, ...git.cd], targets: [], isSudo: peeled.isSudo, ...git.finding, ...(unknown ? { unknown } : {}) })
218 }
219 }
220 return cd
221}
222
223function redirects(redirs: Redir[], item: Item, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): void {
224 for (const r of redirs) {
225 if (!r.target || r.target.parts.length === 0) continue
226 subs(r.target, cd, vars, env, out, depth)
227 const overwrites = r.op === '>' || r.op === '>|' || r.op === '&>' || r.op === '>&'
228 if (!overwrites) continue
229 const target = resolve(r.target, src, vars, env)
230 if (target.known && (target.text.startsWith('/dev/') || target.text === '')) continue
231 out.push({
232 kind: 'redirect',
233 label: src.slice(item.start, item.end).trim(),
234 cd,
235 targets: [target],
236 isSudo: false,
237 ...(target.known ? {} : { unknown: `it writes to ${target.raw}, which is only known when it runs` }),
238 })
239 }
240}
241
242/** Walks the commands a word runs inside it (`$(...)`, backticks). */
243function subs(w: RawWord, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): void {
244 if (depth >= MAX_DEPTH) return
245 for (const p of w.parts) {
246 if (p.t === 'sub') walk(parse(p.src), p.src, cd, new Map(vars), env, out, depth + 1)
247 }
248}
249
250// ---------------------------------------------------------------- words
251
252function resolve(w: RawWord, src: string, vars: Vars, env: ClassifyEnv): Word {
253 let text = ''
254 let known = true
255 let glob = false
256 let plain = true
257 w.parts.forEach((p: Part, index) => {
258 if (p.t === 'lit') {
259 let s = p.s
260 if (index === 0 && !p.quoted && s.startsWith('~')) {
261 plain = false
262 const user = /^~([^/]*)/.exec(s)?.[1] ?? ''
263 if (user === '' && env.home !== undefined) s = env.home + s.slice(1)
264 else known = false
265 }
266 if (!p.quoted && /[*?[]/.test(s)) glob = true
267 // An unquoted brace may expand (or be refused) by the shell: not a plain path.
268 if (!p.quoted && /[{}]/.test(s)) plain = false
269 text += s
270 } else if (p.t === 'var') {
271 plain = false
272 const value = vars.has(p.name) ? vars.get(p.name) : p.name === 'HOME' ? env.home : undefined
273 // Unquoted, bash splits the value on spaces and globs it: one word no more.
274 if (value === undefined || (!p.quoted && /[\s*?[]/.test(value))) known = false
275 else text += value
276 } else {
277 plain = false
278 known = false
279 }
280 })
281 return { text, known, glob, raw: src.slice(w.start, w.end), plain: plain && !glob }
282}
283
284export function literalWord(text: string): Word {
285 return { text, known: true, glob: false, raw: text, plain: true }
286}
287
288/** Resolves a word, expanding braces (`{a,b}`, `{1..3}`) the way the shell would first. */
289function resolveAll(w: RawWord, src: string, vars: Vars, env: ClassifyEnv): Word[] {
290 const only = w.parts.length === 1 ? w.parts[0] : undefined
291 // Bash does not brace-expand an assignment (`X={a,b}`), so neither do we.
292 const isBraced = only?.t === 'lit' && !only.quoted && /\{[^{}]*(,|\.\.)[^{}]*\}/.test(only.s) && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(only.s)
293 if (!isBraced || only?.t !== 'lit') return [resolve(w, src, vars, env)]
294 const expanded = braces(only.s)
295 const raw = src.slice(w.start, w.end)
296 // Past the cap the word is unknown, so it is held, never cut short.
297 if (!expanded) return [{ text: only.s, known: false, glob: false, raw, plain: false }]
298 return expanded.map(s => ({ ...resolve({ ...w, parts: [{ t: 'lit', s, quoted: false }] }, src, vars, env), plain: false }))
299}
300
301/** Most words one brace expression may become, and most nested groups, before it counts as unknown. */
302const BRACE_CAP = 256
303const BRACE_GROUPS = 8
304
305/**
306 * Brace expansion with bounded work: comma lists and `{1..9}` / `{a..e}`
307 * ranges, generated one at a time; undefined once past the cap.
308 */
309export function braces(s: string): string[] | undefined {
310 const out: string[] = []
311 const expand = (text: string, depth: number): boolean => {
312 if (depth > BRACE_GROUPS) return false
313 const group = braceGroup(text)
314 if (!group) {
315 out.push(text)
316 return out.length <= BRACE_CAP
317 }
318 if (group.alternatives === undefined) return false
319 for (const alternative of group.alternatives) {
320 if (!expand(group.head + alternative + group.tail, depth + 1)) return false
321 }
322 return true
323 }
324 return expand(s, 0) ? out : undefined
325}
326
327/** The first `{...}` that expands: its alternatives (undefined: too many), and what is around it. */
328function braceGroup(s: string): { head: string; tail: string; alternatives: string[] | undefined } | undefined {
329 for (let open = s.indexOf('{'); open !== -1; open = s.indexOf('{', open + 1)) {
330 let depth = 0
331 const commas: number[] = []
332 for (let j = open; j < s.length; j++) {
333 if (s[j] === '{') depth++
334 else if (s[j] === '}') depth--
335 else if (s[j] === ',' && depth === 1) commas.push(j)
336 if (depth !== 0) continue
337 const head = s.slice(0, open)
338 const tail = s.slice(j + 1)
339 const body = s.slice(open + 1, j)
340 if (commas.length > 0) {
341 const cuts = [open, ...commas, j]
342 return { head, tail, alternatives: cuts.slice(1).map((cut, k) => s.slice((cuts[k] ?? open) + 1, cut)) }
343 }
344 const range = /^(-?\d+|[a-zA-Z])\.\.(-?\d+|[a-zA-Z])$/.exec(body)
345 if (range) return { head, tail, alternatives: sequence(range[1] ?? '', range[2] ?? '') }
346 break
347 }
348 }
349 return undefined
350}
351
352function sequence(from: string, to: string): string[] | undefined {
353 const isNumber = /\d/.test(from)
354 if (isNumber !== /\d/.test(to)) return undefined
355 const a = isNumber ? Number(from) : from.charCodeAt(0)
356 const b = isNumber ? Number(to) : to.charCodeAt(0)
357 if (Math.abs(b - a) >= BRACE_CAP) return undefined
358 const step = a <= b ? 1 : -1
359 const out: string[] = []
360 for (let n = a; step > 0 ? n <= b : n >= b; n += step) out.push(isNumber ? String(n) : String.fromCharCode(n))
361 return out
362}
363
364function isAssignment(w: Word): boolean {
365 return /^[A-Za-z_][A-Za-z0-9_]*=/.test(w.raw)
366}
367
368export function base(path: string): string {
369 return path.slice(path.lastIndexOf('/') + 1)
370}
371
372// ---------------------------------------------------------------- prefixes
373
374type Peeled = { argv: Word[]; cd: CdStep[]; isSudo: boolean; viaXargs: boolean }
375
376/** Options of each prefix command that take a value of their own. */
377const VALUED: Record<string, readonly string[]> = {
378 sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U', '--user', '--group', '--chdir'],
379 doas: ['-u', '-C'],
380 env: ['-u', '-C', '-S', '-P', '--unset', '--chdir', '--split-string'],
381 nice: ['-n', '--adjustment'],
382 ionice: ['-c', '-n', '-p', '-P', '-u', '--class', '--classdata'],
383 stdbuf: ['-i', '-o', '-e', '--input', '--output', '--error'],
384 timeout: ['-s', '-k', '--signal', '--kill-after'],
385 exec: ['-a'],
386 xargs: ['-E', '-I', '-L', '-n', '-P', '-s', '-d', '-a', '--arg-file', '--delimiter', '--max-args', '--max-procs', '--max-chars', '--max-lines', '--replace', '--eof'],
387}
388const PREFIXES = new Set([...Object.keys(VALUED), 'nohup', 'time', 'command', 'builtin', 'chronic', 'caffeinate', 'unbuffer'])
389
390/** Strips `sudo`, `env X=1`, `nohup`, `xargs` and kin down to the real program. */
391function peel(words: Word[]): Peeled {
392 let argv = words
393 const cd: CdStep[] = []
394 let isSudo = false
395 let viaXargs = false
396 for (let guard = 0; guard < 8; guard++) {
397 const head = argv[0]
398 if (!head?.known) break
399 const name = base(head.text)
400 if (!PREFIXES.has(name)) break
401 if (name === 'command' && argv.slice(1).some(w => w.text === '-v' || w.text === '-V')) return { argv: [], cd, isSudo, viaXargs }
402 if (name === 'sudo' || name === 'doas') isSudo = true
403 if (name === 'xargs') viaXargs = true
404 const valued = VALUED[name] ?? []
405 let k = 1
406 while (k < argv.length) {
407 const w = argv[k]
408 if (!w?.known) break
409 if (w.text === '--') {
410 k++
411 break
412 }
413 if (name === 'env' && isAssignment(w)) {
414 k++
415 continue
416 }
417 if (!w.text.startsWith('-') || w.text === '-') break
418 const flag = w.text.split('=')[0] ?? ''
419 const takesValue = valued.includes(flag) && !w.text.includes('=')
420 const value = takesValue ? argv[k + 1] : w.text.includes('=') ? { ...w, text: w.text.slice(w.text.indexOf('=') + 1) } : undefined
421 if ((name === 'env' && (flag === '-C' || flag === '--chdir')) || (name === 'sudo' && (flag === '-D' || flag === '--chdir'))) {
422 if (value) cd.push({ dir: value, soft: false })
423 }
424 k += takesValue ? 2 : 1
425 }
426 // `timeout 10 cmd`: the duration comes before the command.
427 if (name === 'timeout' && argv[k]?.known && /^[\d.]+[smhd]?$/.test(argv[k]?.text ?? '')) k++
428 argv = argv.slice(k)
429 // `sudo FOO=1 rm ...`: variables set for the command, not the command.
430 while (argv[0] && isAssignment(argv[0])) argv = argv.slice(1)
431 }
432 return { argv, cd, isSudo, viaXargs }
433}
434
435/** `bash -c 'script'`: the script, or undefined when there is no `-c`. */
436function shellScript(argv: Word[]): Word | undefined {
437 let hasC = false
438 for (let k = 1; k < argv.length; k++) {
439 const w = argv[k]
440 if (!w) break
441 if (w.known && /^-[a-zA-Z]+$/.test(w.text)) {
442 if (w.text.includes('c')) hasC = true
443 if (w.text === '-o' || w.text === '-O') k++
444 continue
445 }
446 if (w.known && w.text.startsWith('--')) continue
447 return hasC ? w : undefined
448 }
449 return undefined
450}
451
452// ---------------------------------------------------------------- rm
453
454export function parseRm(argv: Word[]): { isRecursive: boolean; operands: Word[] } {
455 let isRecursive = false
456 let isEnd = false
457 const operands: Word[] = []
458 for (const w of argv.slice(1)) {
459 const t = w.text
460 if (!isEnd && w.known && t === '--') isEnd = true
461 else if (!isEnd && w.known && t.startsWith('--')) {
462 // GNU takes any unique prefix of a long option: --rec, --recur.
463 if (t.length >= 3 && '--recursive'.startsWith(t)) isRecursive = true
464 } else if (!isEnd && w.known && t.startsWith('-') && t.length > 1) {
465 if (/[rR]/.test(t)) isRecursive = true
466 } else operands.push(w)
467 }
468 return { isRecursive, operands }
469}
470
471// ---------------------------------------------------------------- find
472
473const FIND_LEADING = new Set(['-H', '-L', '-P', '-E', '-X', '-s', '-x', '-d'])
474const FIND_VALUED = new Set([
475 '-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-lname', '-ilname',
476 '-type', '-xtype', '-size', '-perm', '-user', '-group', '-uid', '-gid', '-maxdepth', '-mindepth', '-mtime',
477 '-atime', '-ctime', '-mmin', '-amin', '-cmin', '-newer', '-anewer', '-cnewer', '-samefile', '-inum', '-links',
478 '-fstype', '-used', '-context', '-printf', '-fprint', '-fprint0', '-fls', '-regextype', '-newermt', '-newerct',
479 '-newerat', '-newerBt',
480])
481const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
482/**
483 * Predicates that write files or run commands. Never copied into a measuring
484 * command: a find holding one is held unmeasured (GNU find opens -fprint
485 * files for writing while it parses, before it runs anything).
486 */
487export const FIND_WRITES = new Set(['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls'])
488/** Tests whose meaning differs between the system find and the shell's own (bfs, GNU). */
489const FIND_UNPORTABLE = new Set(['-regex', '-iregex', '-regextype', '-readable', '-writable', '-executable'])
490/** Actions that write or print on their own: a listing re-run would not be clean. */
491const FIND_SIDE_EFFECTS = new Set(['-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls', '-delete', '-print', '-print0', '-printf', '-ls'])
492
493export function parseFind(argv: Word[]): Partial<Finding> | undefined {
494 const args = argv.slice(1)
495 let k = 0
496 const leading: string[] = []
497 while (k < args.length) {
498 const t = args[k]?.text ?? ''
499 if (FIND_LEADING.has(t) || /^-O\d$/.test(t)) {
500 leading.push(t)
501 k++
502 } else if (t === '-D' || t === '-f') {
503 leading.push(t, args[k + 1]?.text ?? '')
504 k += 2
505 } else break
506 }
507 const starts: Word[] = []
508 while (k < args.length) {
509 const t = args[k]?.text ?? ''
510 if (args[k]?.known && (t.startsWith('-') || t === '(' || t === '!' || t === ')' || t === ',')) break
511 const w = args[k]
512 if (w) starts.push(w)
513 k++
514 }
515 const expr = args.slice(k)
516
517 // Scan the expression, skipping the values of tests that take one.
518 let deletes = false
519 let usesDelete = false
520 const listing: string[] = []
521 const isKnown = expr.every(w => w.known) && starts.every(w => w.known && !w.glob)
522 let isListable = true
523 let isPortable = true
524 for (let j = 0; j < expr.length; j++) {
525 const t = expr[j]?.text ?? ''
526 // Writers first: -fprint takes a value, but must never reach the listing.
527 if (t === '-fprint' || t === '-fprint0' || t === '-fprintf' || t === '-fls') {
528 isListable = false
529 j += t === '-fprintf' ? 2 : 1
530 continue
531 }
532 if (FIND_UNPORTABLE.has(t)) isPortable = false
533 if (FIND_VALUED.has(t)) {
534 listing.push(t, expr[j + 1]?.text ?? '')
535 j++
536 continue
537 }
538 if (t === '-delete') {
539 deletes = true
540 usesDelete = true
541 listing.push('-print0')
542 continue
543 }
544 if (FIND_EXEC.has(t)) {
545 let end = j + 1
546 while (end < expr.length && expr[end]?.text !== ';' && expr[end]?.text !== '+') end++
547 // `-exec sudo rm`, `-exec env rm`: peel the same prefixes as a command line.
548 const command = peel(expr.slice(j + 1, end)).argv
549 const program = command[0]?.known ? base(command[0].text) : ''
550 if (program === 'rm') {
551 deletes = true
552 listing.push('-print0')
553 } else if (SHELLS.has(program) || program === 'eval' || program === 'xargs' || !command[0]?.known) {
554 // A shell or a run-time program could delete anything: held unmeasured.
555 deletes = true
556 isListable = false
557 } else {
558 isListable = false
559 }
560 j = end
561 continue
562 }
563 if (FIND_SIDE_EFFECTS.has(t)) isListable = false
564 listing.push(t)
565 }
566 if (!deletes) return undefined
567 const from = starts.length > 0 ? starts : [{ text: '.', known: true, glob: false, raw: '.', plain: true }]
568 if (!isKnown) return { targets: from, unknown: 'it names paths that are only known when it runs' }
569 if (!isListable) return { targets: from, unknown: 'its find expression does more than delete, so it was not re-run to look' }
570 if (!isPortable) return { targets: from, unknown: 'it uses a find test the system find reads differently from the shell' }
571 // -delete turns on -depth, which also switches -prune off: list the same way.
572 return { targets: from, findListing: [...leading, ...from.map(w => w.text), ...(usesDelete ? ['-depth'] : []), ...listing] }
573}
574
575// ---------------------------------------------------------------- git
576
577/** Options of a branch switch that cannot discard anything. */
578const SWITCH_FLAGS = new Set(['-q', '--quiet', '-t', '--track', '--no-track', '--detach', '--progress', '--no-progress', '--guess', '--no-guess'])
579
580type GitParsed = { cd: CdStep[]; finding: Pick<Finding, 'kind' | 'git' | 'unknown'> }
581
582function shortFlags(t: string): string {
583 return /^-[A-Za-z]+$/.test(t) ? t.slice(1) : ''
584}
585
586export function parseGit(argv: Word[], viaXargs = false): GitParsed | undefined {
587 const cd: CdStep[] = []
588 let k = 1
589 let unknown: string | undefined
590 while (k < argv.length) {
591 const w = argv[k]
592 if (!w?.known || !w.text.startsWith('-')) break
593 if (w.text === '-C') {
594 const dir = argv[k + 1]
595 if (dir) cd.push({ dir, soft: false })
596 k += 2
597 } else if (w.text === '-c' || w.text === '--git-dir' || w.text === '--work-tree' || w.text === '--namespace') {
598 if (w.text !== '-c') unknown = 'it points git at another repository'
599 k += 2
600 } else {
601 if (w.text.startsWith('--git-dir=') || w.text.startsWith('--work-tree=')) unknown = 'it points git at another repository'
602 k++
603 }
604 }
605 const sub = argv[k]
606 if (!sub?.known) return undefined
607 const rest = argv.slice(k + 1)
608 const parsed = gitSub(sub.text, rest, viaXargs)
609 if (!parsed) return undefined
610 return { cd, finding: unknown ? { ...parsed, unknown } : parsed }
611}
612
613function gitSub(sub: string, rest: Word[], viaXargs: boolean): Pick<Finding, 'kind' | 'git'> | undefined {
614 const positionals: Word[] = []
615 const after: Word[] = []
616 let isEnd = false
617 const flags: string[] = []
618 for (let j = 0; j < rest.length; j++) {
619 const w = rest[j]
620 if (!w) continue
621 if (isEnd) after.push(w)
622 else if (w.known && w.text === '--') isEnd = true
623 else if (w.known && w.text.startsWith('-') && w.text !== '-') {
624 flags.push(w.text)
625 // Options whose value is a separate word.
626 const valued: Record<string, readonly string[]> = {
627 clean: ['-e', '--exclude'],
628 checkout: ['-b', '-B', '-c', '-C', '--orphan', '--conflict'],
629 restore: ['-s', '--source'],
630 push: ['-o', '--push-option', '--repo', '--receive-pack', '--exec'],
631 branch: ['-u', '--set-upstream-to', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--format', '--sort'],
632 }
633 if ((valued[sub] ?? []).includes(w.text)) {
634 const value = rest[j + 1]
635 if (value) flags.push(value.text)
636 j++
637 }
638 } else positionals.push(w)
639 }
640 const has = (...names: string[]) => flags.some(f => names.includes(f))
641 const hasShort = (ch: string) => flags.some(f => shortFlags(f).includes(ch))
642
643 if (sub === 'clean') {
644 const forces = flags.reduce((n, f) => n + (f === '--force' ? 1 : (shortFlags(f).match(/f/g) ?? []).length), 0)
645 if (forces === 0 || hasShort('n') || has('--dry-run')) return undefined
646 const keep = ['d', 'x', 'X'].filter(hasShort)
647 const excludes: string[] = []
648 flags.forEach((f, j) => {
649 if (f === '-e' || f === '--exclude') excludes.push(flags[j + 1] ?? '')
650 else if (f.startsWith('--exclude=')) excludes.push(f.slice('--exclude='.length))
651 })
652 return { kind: 'git-clean', git: { sub: 'clean', flags: keep, forces, excludes, paths: [...positionals, ...after] } }
653 }
654 if (sub === 'worktree') {
655 // `git worktree remove --force` deletes the worktree folder, uncommitted work and all.
656 const [action, ...targets] = [...positionals, ...after]
657 if (action?.text !== 'remove' || !(has('--force') || hasShort('f'))) return undefined
658 return { kind: 'git-worktree-remove', git: { sub: 'worktree', paths: targets } }
659 }
660 if (sub === 'reset') {
661 if (!has('--hard')) return undefined
662 const ref = positionals[0]
663 return { kind: 'git-reset-hard', git: ref ? { sub: 'reset', ref } : { sub: 'reset' } }
664 }
665 if (sub === 'checkout') {
666 const isForced = has('-f', '--force')
667 if (has('-p', '--patch')) return undefined
668 if (!isForced && has('-b', '-B', '-c', '-C', '--orphan')) return undefined
669 const discard = (mode: 'all' | 'paths' | 'maybe-ref', paths: Word[], source?: Word): Pick<Finding, 'kind' | 'git'> => ({
670 kind: 'git-discard',
671 git: { sub: 'discard', mode, paths, fromIndex: source === undefined, ...(source ? { source } : {}) },
672 })
673 if (isEnd) {
674 if (after.length > 0) return discard('paths', after, positionals[0])
675 return isForced ? discard('all', []) : undefined
676 }
677 if (isForced) return discard('all', [])
678 const [first, ...others] = positionals
679 if (!first || (first.known && first.text === '-')) return undefined
680 if (others.length > 0) return discard('paths', others, first)
681 // Only a bare `git checkout <plain name>` may turn out to be a branch switch
682 // (the measurer asks git). Any other option, or a name that is not a plain
683 // token, is taken as paths and held.
684 const isBare = flags.every(f => SWITCH_FLAGS.has(f.split('=')[0] ?? ''))
685 const isPath = !isBare || !first.plain || first.text === '.' || first.text.startsWith('./') || first.text.startsWith(':/')
686 return discard(isPath ? 'paths' : 'maybe-ref', [first])
687 }
688 if (sub === 'restore') {
689 const staged = hasShort('S') || has('--staged')
690 const worktree = hasShort('W') || has('--worktree')
691 if ((staged && !worktree) || hasShort('p') || has('--patch')) return undefined
692 const paths = [...positionals, ...after]
693 if (paths.length === 0) return undefined
694 const sourceAt = flags.findIndex(f => f === '-s' || f === '--source')
695 const sourceText = sourceAt >= 0 ? flags[sourceAt + 1] : flags.find(f => f.startsWith('--source='))?.slice('--source='.length)
696 const source = sourceText !== undefined ? literalWord(sourceText) : staged ? literalWord('HEAD') : undefined
697 return { kind: 'git-discard', git: { sub: 'discard', mode: 'paths', paths, fromIndex: source === undefined, ...(source ? { source } : {}) } }
698 }
699 if (sub === 'switch') {
700 if (!has('--discard-changes', '-f', '--force')) return undefined
701 return { kind: 'git-discard', git: { sub: 'discard', mode: 'all', paths: [], fromIndex: false } }
702 }
703 if (sub === 'push') {
704 const name = (f: string) => f.split('=')[0] ?? ''
705 // git takes a unique prefix of a long option: --force-with is the lease.
706 const isMirror = flags.some(f => name(f) === '--mirror')
707 const isForced = has('--force') || hasShort('f') || isMirror || [...positionals, ...after].slice(1).some(w => w.text.startsWith('+'))
708 // A lease guards only a push that is not also forced: `--force` (or -f,
709 // a `+refspec`, --mirror) switches the lease off, so it is held.
710 if (!isForced || has('--dry-run') || hasShort('n')) return undefined
711 const isWholeRepo = isMirror || has('--all', '--branches', '--tags')
712 const [remote, ...refspecs] = [...positionals, ...after]
713 return { kind: 'git-push-force', git: { sub: 'push', refspecs, isWholeRepo, ...(remote ? { remote } : {}) } }
714 }
715 if (sub === 'branch') {
716 const isDelete = hasShort('d') || hasShort('D') || has('--delete')
717 const isForced = hasShort('D') || hasShort('f') || has('--force')
718 if (!isDelete || !isForced || hasShort('r') || has('--remotes')) return undefined
719 // Names after `--` are names too: `git branch -D -- feature`.
720 const names = [...positionals, ...after]
721 return names.length > 0 || viaXargs ? { kind: 'git-branch-delete', git: { sub: 'branch', names } } : undefined
722 }
723 return undefined
724}
725
726export { literal }
727hooks/plan.ts 218 lines1// The pure part of mulligan: from what the classifier found in a command, what
2// a snapshot must hold so /mulligan can put the project back; and the words.
3//
4// A positive list of shapes, never "everything not matched is safe": the
5// classifier names the commands that destroy files (rm, find -delete, xargs
6// rm, git clean, git reset --hard, git checkout or restore over changes,
7// git worktree remove, a redirect over a file). Pushes and branch deletions
8// touch no file here and take no snapshot.
9
10import type { CdStep, Finding, Word } from './classify'
11import { join, normalize, within } from './paths'
12
13/** What one snapshot holds. */
14export type Scope = {
15 /** Every file under the project (git: tracked changes and untracked files). */
16 isTree: boolean
17 /** git: the tracked files' state, through `git stash create` (HEAD when nothing changed). */
18 isTracked: boolean
19 /** git: untracked files that are not ignored. */
20 isUntracked: boolean
21 /** Paths under the project the command acts on, absolute, none inside another. */
22 paths: string[]
23}
24
25export type Plan = {
26 scope: Scope
27 /** Targets outside the project, which no snapshot covers. */
28 outside: string[]
29 /** What the snapshot leaves out, in words (ignored files under git clean -x). */
30 notes: string[]
31}
32
33export const EMPTY_SCOPE: Scope = { isTree: false, isTracked: false, isUntracked: false, paths: [] }
34
35/** True when the scope holds nothing to save. */
36export function isEmpty(scope: Scope): boolean {
37 return !scope.isTree && !scope.isTracked && !scope.isUntracked && scope.paths.length === 0
38}
39
40/** Where a finding runs: the shell's directory through its `cd`s, or undefined when a `cd` is unknown ahead. */
41export function whereItRuns(cwd: string, steps: readonly CdStep[]): string | undefined {
42 let dir = cwd
43 for (const step of steps) {
44 if (!step.dir.known) return undefined
45 dir = join(dir, step.dir.text)
46 }
47 return dir
48}
49
50/**
51 * The part of a path written before its first pattern: `build/*` is `build`, `**` is nothing.
52 * Undefined when the word holds an expansion nobody can know ahead.
53 */
54export function staticPart(word: Word): string | undefined {
55 if (!word.known) return undefined
56 if (!word.glob) return word.text
57 const segments: string[] = []
58 for (const segment of word.text.split('/')) {
59 if (/[*?[{]/.test(segment)) break
60 segments.push(segment)
61 }
62 return segments.join('/')
63}
64
65/**
66 * What to save before `findings` run from the shell's directory `cwd`, in a project at `root`.
67 * Null when nothing in the command touches files (no finding, or only pushes and branch deletes).
68 */
69export function planFor(findings: readonly Finding[], cwd: string, root: string, isGit: boolean): Plan | null {
70 const scope: Scope = { ...EMPTY_SCOPE, paths: [] }
71 const outside: string[] = []
72 const notes: string[] = []
73 let isFiles = false
74 const add = (dir: string | undefined, target: Word) => {
75 const part = dir === undefined ? undefined : staticPart(target)
76 if (dir === undefined || part === undefined || part === '') {
77 scope.isTree = true // where or what is only known when it runs: save the whole project
78 return
79 }
80 const path = join(dir, part)
81 if (!within(root, path)) outside.push(path)
82 else if (path === normalize(root)) scope.isTree = true
83 else scope.paths.push(path)
84 }
85 for (const f of findings) {
86 const dir = whereItRuns(cwd, f.cd)
87 switch (f.kind) {
88 case 'rm':
89 case 'find-delete':
90 case 'redirect':
91 isFiles = true
92 if (f.targets.length === 0) scope.isTree = true
93 for (const target of f.targets) add(dir, target)
94 break
95 case 'xargs-rm':
96 isFiles = true
97 scope.isTree = true // it deletes whatever the command before it prints
98 break
99 case 'git-worktree-remove':
100 isFiles = true
101 for (const target of f.git?.sub === 'worktree' ? f.git.paths : []) add(dir, target)
102 break
103 case 'git-clean':
104 isFiles = true
105 if (dir !== undefined && !within(root, dir)) outside.push(dir) // git -C another repository
106 else if (isGit) scope.isUntracked = true
107 else scope.isTree = true
108 if (f.git?.sub === 'clean' && f.git.flags.some(flag => /x/i.test(flag.replace(/^--?/, '')))) {
109 notes.push('ignored files (git clean -x) are not saved')
110 }
111 break
112 case 'git-reset-hard':
113 case 'git-discard':
114 isFiles = true
115 if (dir !== undefined && !within(root, dir)) outside.push(dir)
116 else if (isGit) scope.isTracked = true
117 else scope.isTree = true
118 break
119 case 'git-push-force':
120 case 'git-branch-delete':
121 break // no file in the project changes
122 }
123 }
124 if (!isFiles) return null
125 if (scope.isTree) {
126 scope.paths = []
127 if (isGit) {
128 scope.isTracked = true
129 scope.isUntracked = true
130 }
131 }
132 scope.paths = outermost(scope.paths)
133 return { scope, outside: [...new Set(outside)], notes }
134}
135
136/** The whole project, for the person's own `always` pattern. */
137export function treePlan(isGit: boolean): Plan {
138 return { scope: { isTree: true, isTracked: isGit, isUntracked: isGit, paths: [] }, outside: [], notes: [] }
139}
140
141/** Paths with every one inside another dropped: `build` covers `build/x`. */
142export function outermost(paths: readonly string[]): string[] {
143 const sorted = [...new Set(paths)].sort()
144 return sorted.filter(path => !sorted.some(other => other !== path && within(other, path)))
145}
146
147/** A path as the person reads it: relative to the project, else as it is. */
148export function shown(path: string, root: string): string {
149 if (path === root) return '.'
150 return within(root, path) ? path.slice(root === '/' ? 1 : root.length + 1) : path
151}
152
153/** What a snapshot holds, in a few words: "build, dist", "tracked changes and untracked files", "the project". */
154export function scopeWords(scope: Scope, root: string): string {
155 if (scope.isTree) return 'the project'
156 const parts: string[] = []
157 if (scope.isTracked) parts.push('tracked changes')
158 if (scope.isUntracked) parts.push('untracked files')
159 if (scope.paths.length > 0) {
160 const names = scope.paths.map(path => shown(path, root))
161 parts.push(names.length <= 3 ? names.join(', ') : `${names.slice(0, 3).join(', ')} and ${names.length - 3} more`)
162 }
163 return parts.join(' and ')
164}
165
166/** One line for a drawing: whitespace runs as one space, at most `max` characters. */
167export function oneLine(text: string, max: number): string {
168 const flat = text.replace(/\s+/g, ' ').trim()
169 return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
170}
171
172/** Control characters and escape sequences out of anything drawn (commands and paths are Claude's). */
173export function clean(text: string): string {
174 return text
175 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
176 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
177 .replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '�')
178}
179
180/** 12345678 bytes as "12 MB". */
181export function sizeWords(bytes: number): string {
182 if (bytes < 1_000) return `${bytes} B`
183 if (bytes < 1_000_000) return `${Math.round(bytes / 1_000)} KB`
184 return `${Math.round(bytes / 100_000) / 10} MB`
185}
186
187/** "3m ago", "2h ago", for the list. */
188export function agoWords(ms: number): string {
189 const minutes = Math.floor(ms / 60_000)
190 if (minutes < 1) return 'just now'
191 if (minutes < 60) return `${minutes}m ago`
192 const hours = Math.floor(minutes / 60)
193 return hours < 48 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
194}
195
196/** FNV-1a over the project root: a short key for its folder that does not spell the path. */
197export function projectKey(root: string): string {
198 let h = 0x811c9dc5
199 for (let i = 0; i < root.length; i++) {
200 h ^= root.charCodeAt(i)
201 h = Math.imul(h, 0x01000193) >>> 0
202 }
203 let g = 0x01000193
204 for (let i = root.length - 1; i >= 0; i--) {
205 g ^= root.charCodeAt(i)
206 g = Math.imul(g, 0x811c9dc5) >>> 0
207 }
208 return h.toString(16).padStart(8, '0') + g.toString(16).padStart(8, '0')
209}
210
211/** The note Claude reads after a restore: its view of the files is stale. */
212export function restoreNote(command: string, n: number): string {
213 return (
214 `The person restored files in this project to how they were before the command \`${oneLine(command, 200)}\` ` +
215 `(mulligan snapshot ${n}). Files may differ from what you last read: re-read any file before editing it.`
216 )
217}
218hooks/shell.ts 405 lines1// The same file as last-look's hooks/shell.ts, copied so this mod installs on its own.
2// Keep the two in step: a fix to one is a fix to both.
3
4// A small, forgiving shell parser: just enough of bash's grammar to see which
5// commands a string runs, with which words and redirects, in which order.
6//
7// It never throws and always ends: on input bash would reject (an open quote,
8// a stray paren) it keeps going with its best reading, because a guard that
9// stops at a parse error would let the rest of the string through unread.
10
11/** One piece of a word, as written. */
12export type Part =
13 | { t: 'lit'; s: string; quoted: boolean }
14 /** `quoted`: inside double quotes, so the shell neither splits nor globs its value. */
15 | { t: 'var'; name: string; quoted: boolean }
16 /** `$( ... )`, backticks or `<( ... )`: a command of its own. */
17 | { t: 'sub'; src: string }
18 /** An expansion nobody can know ahead (`$1`, `$((n+1))`, `${x:-y}`). */
19 | { t: 'opaque' }
20
21export type RawWord = { parts: Part[]; start: number; end: number }
22
23export type Redir = {
24 op: string
25 fd?: string
26 /** The file it names; absent for a here-doc or a descriptor copy (`2>&1`). */
27 target?: RawWord
28 /** For a here-doc: its delimiter was quoted, so its body is plain data (no `$(...)` runs). */
29 isQuotedDoc?: boolean
30}
31
32export type Simple = {
33 kind: 'simple'
34 words: RawWord[]
35 redirs: Redir[]
36 start: number
37 end: number
38}
39
40export type Group = {
41 kind: 'group'
42 /** `( ... )` runs in a subshell; `{ ...; }` does not, so its `cd` sticks. */
43 subshell: boolean
44 body: Seq
45 redirs: Redir[]
46 start: number
47 end: number
48}
49
50/** A command and the operator after it: `&&`, `||`, `;`, `|`, `&`, or ``. */
51export type Item = (Simple | Group) & { op: string }
52export type Seq = Item[]
53
54const BLANK = new Set([' ', '\t', '\r'])
55const ENDS_WORD = new Set([' ', '\t', '\r', '\n', ';', '&', '|', '(', ')', '<', '>'])
56const REDIRECT = /^(\d*)(&>>|&>|>>|>\||>&|<<<|<<-|<<|<>|<&|>|<)/
57
58export function parse(src: string): Seq {
59 return new Parser(src).seq(false)
60}
61
62class Parser {
63 private i = 0
64 private heredocs: { delim: string; strip: boolean }[] = []
65
66 constructor(private readonly src: string) {}
67
68 seq(close: ')' | '}' | false): Seq {
69 const items: Seq = []
70 while (true) {
71 this.blank()
72 if (this.i >= this.src.length) return items
73 const c = this.src[this.i]
74 if (c === '\n') {
75 this.newline()
76 continue
77 }
78 if (c === ')') {
79 this.i++
80 if (close === ')') return items
81 continue
82 }
83 if (c === ';' || c === '&' || c === '|') {
84 this.i++
85 continue
86 }
87 if (close === '}' && this.atWord('}')) {
88 this.i++
89 return items
90 }
91 const node = this.command()
92 this.blank()
93 items.push({ ...node, op: this.operator() })
94 }
95 }
96
97 private command(): Simple | Group {
98 const start = this.i
99 if (this.src.startsWith('((', this.i)) {
100 // Arithmetic `(( n > 2 ))`: its `>` compares, it redirects nothing.
101 this.i = this.balanced(this.i)
102 return { kind: 'simple', words: [], redirs: [], start, end: this.i }
103 }
104 if (this.src[this.i] === '(') {
105 this.i++
106 const body = this.seq(')')
107 return { kind: 'group', subshell: true, body, redirs: this.redirects(), start, end: this.i }
108 }
109 if (this.atWord('{')) {
110 this.i++
111 const body = this.seq('}')
112 return { kind: 'group', subshell: false, body, redirs: this.redirects(), start, end: this.i }
113 }
114 return this.simple()
115 }
116
117 private simple(): Simple {
118 const start = this.i
119 const words: RawWord[] = []
120 const redirs: Redir[] = []
121 while (true) {
122 this.blank()
123 const c = this.src[this.i]
124 if (c === undefined || c === '\n' || c === ';' || c === '|' || c === ')' || c === '(') break
125 if (c === '&' && this.src[this.i + 1] !== '>') break
126 if ((c === '<' || c === '>') && this.src[this.i + 1] === '(') {
127 words.push(this.processSub())
128 continue
129 }
130 const redir = this.redirect()
131 if (redir) {
132 redirs.push(redir)
133 continue
134 }
135 const before = this.i
136 const word = this.word()
137 if (this.i === before) {
138 this.i++
139 continue
140 }
141 words.push(word)
142 }
143 return { kind: 'simple', words, redirs, start, end: this.i }
144 }
145
146 /** Redirects after a group's closing paren or brace. */
147 private redirects(): Redir[] {
148 const out: Redir[] = []
149 while (true) {
150 this.blank()
151 const redir = this.redirect()
152 if (!redir) return out
153 out.push(redir)
154 }
155 }
156
157 private redirect(): Redir | undefined {
158 const m = REDIRECT.exec(this.src.slice(this.i, this.i + 8))
159 if (!m) return undefined
160 const fd = m[1] === '' ? undefined : m[1]
161 const op = m[2] ?? ''
162 this.i += m[0].length
163 this.blank()
164 if (op === '<<' || op === '<<-') {
165 const delim = this.word()
166 this.heredocs.push({ delim: literal(delim), strip: op === '<<-' })
167 return { op, fd, isQuotedDoc: delim.parts.some(p => p.t === 'lit' && p.quoted) }
168 }
169 const target = this.word()
170 if ((op === '>&' || op === '<&') && /^(\d+|-)$/.test(literal(target))) return { op, fd }
171 return { op, fd, target }
172 }
173
174 private operator(): string {
175 const two = this.src.slice(this.i, this.i + 2)
176 if (two === '&&' || two === '||' || two === '|&' || two === ';;') {
177 this.i += 2
178 return two === ';;' ? ';' : two
179 }
180 const c = this.src[this.i]
181 if (c === ';' || c === '|' || c === '&') {
182 this.i++
183 return c
184 }
185 if (c === '\n') {
186 this.newline()
187 return ';'
188 }
189 return ''
190 }
191
192 /** Is the next word exactly `w`, unquoted and standing alone? */
193 private atWord(w: string): boolean {
194 if (!this.src.startsWith(w, this.i)) return false
195 const after = this.src[this.i + w.length]
196 return after === undefined || ENDS_WORD.has(after)
197 }
198
199 /** Spaces, tabs, line continuations and comments; never a newline. */
200 private blank(): void {
201 while (this.i < this.src.length) {
202 const c = this.src[this.i]
203 if (c !== undefined && BLANK.has(c)) this.i++
204 else if (c === '\\' && this.src[this.i + 1] === '\n') this.i += 2
205 else if (c === '#') {
206 while (this.i < this.src.length && this.src[this.i] !== '\n') this.i++
207 } else return
208 }
209 }
210
211 /** Consumes a newline, then the bodies of any here-docs opened on its line. */
212 private newline(): void {
213 this.i++
214 for (const doc of this.heredocs) {
215 while (this.i < this.src.length) {
216 const cut = this.src.indexOf('\n', this.i)
217 const lineEnd = cut === -1 ? this.src.length : cut
218 const line = this.src.slice(this.i, lineEnd)
219 this.i = lineEnd + 1
220 if ((doc.strip ? line.replace(/^\t+/, '') : line) === doc.delim) break
221 }
222 }
223 this.heredocs = []
224 if (this.i > this.src.length) this.i = this.src.length
225 }
226
227 private word(): RawWord {
228 const start = this.i
229 const parts: Part[] = []
230 let lit = ''
231 const flush = () => {
232 if (lit !== '') parts.push({ t: 'lit', s: lit, quoted: false })
233 lit = ''
234 }
235 while (this.i < this.src.length) {
236 const c = this.src[this.i] ?? ''
237 if (ENDS_WORD.has(c)) break
238 if (c === '\\') {
239 const next = this.src[this.i + 1]
240 this.i += 2
241 if (next === undefined || next === '\n') continue
242 flush()
243 parts.push({ t: 'lit', s: next, quoted: true })
244 } else if (c === "'") {
245 flush()
246 const close = this.src.indexOf("'", this.i + 1)
247 const end = close === -1 ? this.src.length : close
248 parts.push({ t: 'lit', s: this.src.slice(this.i + 1, end), quoted: true })
249 this.i = end + 1
250 } else if (c === '"') {
251 flush()
252 this.i++
253 this.doubleQuoted(parts)
254 } else if (c === '$') {
255 flush()
256 this.dollar(parts, false)
257 } else if (c === '`') {
258 flush()
259 parts.push({ t: 'sub', src: this.backtick() })
260 } else {
261 lit += c
262 this.i++
263 }
264 }
265 flush()
266 if (this.i > this.src.length) this.i = this.src.length
267 return { parts, start, end: this.i }
268 }
269
270 private doubleQuoted(parts: Part[]): void {
271 let lit = ''
272 const flush = () => {
273 if (lit !== '') parts.push({ t: 'lit', s: lit, quoted: true })
274 lit = ''
275 }
276 while (this.i < this.src.length) {
277 const c = this.src[this.i] ?? ''
278 if (c === '"') {
279 this.i++
280 break
281 }
282 if (c === '\\') {
283 const next = this.src[this.i + 1] ?? ''
284 if (next === '\n') this.i += 2
285 else if ('$`"\\'.includes(next) && next !== '') {
286 lit += next
287 this.i += 2
288 } else {
289 lit += c
290 this.i++
291 }
292 } else if (c === '$') {
293 flush()
294 this.dollar(parts, true)
295 } else if (c === '`') {
296 flush()
297 parts.push({ t: 'sub', src: this.backtick() })
298 } else {
299 lit += c
300 this.i++
301 }
302 }
303 flush()
304 }
305
306 private dollar(parts: Part[], inQuotes: boolean): void {
307 const next = this.src[this.i + 1] ?? ''
308 if (next === '(') {
309 if (this.src[this.i + 2] === '(') {
310 this.i = this.balanced(this.i + 1)
311 parts.push({ t: 'opaque' })
312 return
313 }
314 const end = this.balanced(this.i + 1)
315 parts.push({ t: 'sub', src: this.src.slice(this.i + 2, Math.max(this.i + 2, end - 1)) })
316 this.i = end
317 } else if (next === '{') {
318 const close = this.src.indexOf('}', this.i + 2)
319 const end = close === -1 ? this.src.length : close
320 const inner = this.src.slice(this.i + 2, end)
321 parts.push(/^[A-Za-z_][A-Za-z0-9_]*$/.test(inner) ? { t: 'var', name: inner, quoted: inQuotes } : { t: 'opaque' })
322 this.i = end + 1
323 } else if (/[A-Za-z_]/.test(next)) {
324 const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.i + 1)) ?? ['']
325 parts.push({ t: 'var', name: m[0], quoted: inQuotes })
326 this.i += 1 + m[0].length
327 } else if (/[0-9@*#?$!-]/.test(next) && next !== '') {
328 parts.push({ t: 'opaque' })
329 this.i += 2
330 } else if (next === "'" && !inQuotes) {
331 // $'...': ANSI-C quoting; the common escapes are enough here.
332 let s = ''
333 let j = this.i + 2
334 while (j < this.src.length && this.src[j] !== "'") {
335 if (this.src[j] === '\\' && j + 1 < this.src.length) {
336 const e = this.src[j + 1] ?? ''
337 s += e === 'n' ? '\n' : e === 't' ? '\t' : e
338 j += 2
339 } else s += this.src[j++]
340 }
341 parts.push({ t: 'lit', s, quoted: true })
342 this.i = j + 1
343 } else if (next === '"' && !inQuotes) {
344 this.i += 2
345 this.doubleQuoted(parts)
346 } else {
347 parts.push({ t: 'lit', s: '$', quoted: inQuotes })
348 this.i++
349 }
350 }
351
352 /** From an opening paren at `open`, the index just past its partner. */
353 private balanced(open: number): number {
354 let depth = 0
355 let j = open
356 while (j < this.src.length) {
357 const c = this.src[j]
358 if (c === '\\') j += 2
359 else if (c === "'") {
360 const close = this.src.indexOf("'", j + 1)
361 j = close === -1 ? this.src.length : close + 1
362 } else if (c === '"') {
363 j++
364 while (j < this.src.length && this.src[j] !== '"') j += this.src[j] === '\\' ? 2 : 1
365 j++
366 } else if (c === '(') {
367 depth++
368 j++
369 } else if (c === ')') {
370 depth--
371 j++
372 if (depth === 0) return j
373 } else j++
374 }
375 return this.src.length
376 }
377
378 private backtick(): string {
379 let j = this.i + 1
380 let s = ''
381 while (j < this.src.length && this.src[j] !== '`') {
382 if (this.src[j] === '\\' && j + 1 < this.src.length) {
383 s += this.src[j + 1]
384 j += 2
385 } else s += this.src[j++]
386 }
387 this.i = j + 1
388 return s
389 }
390
391 /** `<( ... )` or `>( ... )`: a command whose output stands in as a file. */
392 private processSub(): RawWord {
393 const start = this.i
394 const end = this.balanced(this.i + 1)
395 const src = this.src.slice(this.i + 2, Math.max(this.i + 2, end - 1))
396 this.i = end
397 return { parts: [{ t: 'sub', src }], start, end }
398 }
399}
400
401/** A word's text when it holds no expansion; quotes removed. */
402export function literal(w: RawWord): string {
403 return w.parts.map(p => (p.t === 'lit' ? p.s : '')).join('')
404}
405hooks/paths.ts 94 lines1// The same file as last-look's hooks/paths.ts, copied so this mod installs on its own.
2// Keep the two in step: a fix to one is a fix to both.
3
4// Pure POSIX path helpers, and the alwaysAllow match.
5
6export function isAbsolute(path: string): boolean {
7 return path.startsWith('/')
8}
9
10/** Folds `.`, `..` and repeated slashes; keeps a lone `/`. */
11export function normalize(path: string): string {
12 const parts: string[] = []
13 for (const part of path.split('/')) {
14 if (part === '' || part === '.') continue
15 if (part === '..') parts.pop()
16 else parts.push(part)
17 }
18 return `/${parts.join('/')}`
19}
20
21export function join(dir: string, path: string): string {
22 return normalize(isAbsolute(path) ? path : `${dir}/${path}`)
23}
24
25/** True when `path` is `parent` or lies beneath it. */
26export function within(parent: string, path: string): boolean {
27 if (parent === '/') return true
28 return path === parent || path.startsWith(`${parent}/`)
29}
30
31/** Moves `path` from beneath `from` to beneath `to`, or leaves it as it is. */
32export function rebase(path: string, from: string, to: string): string {
33 if (!within(from, path)) return path
34 return normalize(to + path.slice(from.length))
35}
36
37/** How a path reads to the person: relative to where they are, else `~/...`. */
38export function show(path: string, cwd: string, home?: string): string {
39 if (path === cwd) return '.'
40 if (within(cwd, path)) return path.slice(cwd === '/' ? 1 : cwd.length + 1)
41 if (home && within(home, path)) return path === home ? '~' : `~${path.slice(home.length)}`
42 return path
43}
44
45/** The alwaysAllow setting: a comma list of folders, `./` and trailing `/` dropped. */
46export function parseAllow(setting: string): string[] {
47 return setting
48 .split(',')
49 .map(entry => entry.trim().replace(/^(\.\/)+/, '').replace(/\/+$/, ''))
50 .filter(entry => entry !== '' && entry !== '.' && !entry.split('/').includes('..'))
51}
52
53/**
54 * True when `path` lies inside an allowed folder: an absolute entry by
55 * prefix; a relative one (`dist`, `node_modules/.cache`) as a run of folder
56 * names anywhere beneath the project root, never the root itself or outside.
57 */
58export function isAllowed(path: string, root: string, allow: readonly string[]): boolean {
59 return allow.some(entry => {
60 if (isAbsolute(entry)) return within(normalize(entry), path)
61 if (!within(root, path) || path === root) return false
62 const segments = path.slice(root === '/' ? 1 : root.length + 1).split('/')
63 const wanted = entry.split('/')
64 for (let start = 0; start + wanted.length <= segments.length; start++) {
65 if (wanted.every((name, k) => segments[start + k] === name)) return true
66 }
67 return false
68 })
69}
70
71/** A glob segment (`*.log`, `b?ild`, `[ab]*`) as a RegExp, as bash matches names. */
72export function globSegment(segment: string): RegExp {
73 let source = ''
74 for (let k = 0; k < segment.length; k++) {
75 const c = segment[k] ?? ''
76 if (c === '*') source += '[^/]*'
77 else if (c === '?') source += '[^/]'
78 else if (c === '[') {
79 const close = segment.indexOf(']', k + 2)
80 if (close === -1) source += '\\['
81 else {
82 const body = segment.slice(k + 1, close).replace(/^!/, '^').replace(/\\/g, '\\\\')
83 source += `[${body}]`
84 k = close
85 }
86 } else source += c.replace(/[.+^${}()|\\\]]/g, '\\$&')
87 }
88 return new RegExp(`^${source}$`)
89}
90
91export function hasGlob(segment: string): boolean {
92 return /[*?[]/.test(segment)
93}
94types/index.d.ts 38 lines1// What mulligan keeps in $.state for the session: the project's snapshots as
2// last read from its folder, the line above the prompt, and what the pane
3// shows. The snapshots themselves are files under ~/.claude/mulligan.
4
5/** One snapshot, saved before a destructive command ran. */
6export type MulliganSnapshot = {
7 /** Its number in this project, from 1. */
8 n: number
9 /** When it was saved, ms since the epoch. */
10 at: number
11 /** The command it was saved before, cleaned, one line. */
12 command: string
13 /** What it holds, in words ("dist", "tracked changes and untracked files", "the project"). */
14 scope: string
15 /** git: the commit object holding the tracked files' state. */
16 sha?: string
17 /** The archive's file name in the project's folder, when files were copied. */
18 archive?: string
19 /** The archive's size in bytes (0 without one). */
20 bytes: number
21}
22
23/** The line above the prompt after a snapshot, or a skip, until a moment passes. */
24export type MulliganBand = { text: string; tone: 'subtle' | 'warning'; until: number }
25
26/** What the pane shows: the list (with a restore waiting on its confirm), or one snapshot's diff. */
27export type MulliganView = { kind: 'list'; confirm: number } | { kind: 'diff'; n: number; lines: string[] }
28
29declare module 'claude-code' {
30 interface PluginState {
31 mulligan: {
32 snapshots: MulliganSnapshot[]
33 band: MulliganBand | null
34 view: MulliganView
35 }
36 }
37}
38