SLOPSHOPPER

native-edits

File changes through Edit and Write, not sed -i and heredocs: rewrites auto mode's shell-first advice and refuses Bash commands that write project files, so…

newguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · native-edits
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /native-edits ⎿ native-edits: Guard: deny, scope project; always allowed: **/*.log, **/node_modules/**, **/dist/**, **/build/**, /tmp and $T ⎿ native-edits: Shell writes refused: 0 this session, 0 in all. Warned: 0 this session, 0 in all. ⎿ native-edits: Auto-mode note: not seen in this session yet (Claude Code adds it in auto mode and with bypass permissions). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods by Evgeny Padezhnov

Unofficial — not made or endorsed by Anthropic.

Before/after examples, install steps and the measurements behind each plugin: padezhnov.com/en/tools.

Claude Code mods

ModWhat it does
anti-slopFlags comments that narrate the edit ("added X", "as requested") with a note to Claude right after the edit, and drops a few stock openers like "You're absolutely right" from stored replies. English and Russian.
native-editsFile changes through Edit and Write, not sed -i and heredocs: every change gets a diff, can be rewound and follows the path's rules.
/plugin marketplace add padezhnov/claude-code-mods
/plugin install anti-slop@padezhnov
/plugin install native-edits@padezhnov

Mods are Claude Code plugins whose hooks are TypeScript functions running inside Claude Code (since 2.1.287). These need Claude Code 2.1.287 or newer; native-edits' prompt fix applies from 2.1.289. Neither mod makes network requests. anti-slop is about 500 lines of TypeScript; native-edits about 2,200, most of it a shell parser (plugins/native-edits/hooks/shell.ts) — read them before installing.

Settings: /plugin → pick the mod → configure, or pluginConfigs.<mod> in your Claude Code settings. Turn off or remove: /plugin disable anti-slop@padezhnov, /plugin uninstall anti-slop@padezhnov (same for native-edits), or set its mode to off.


anti-slop

Two long-running complaints about Claude Code: it writes comments that narrate the edit instead of explaining the code, and ignores instructions to stop (#65961); and it leans on stock phrases, such as opening with "You're absolutely right" or saying "load-bearing" (#53454 is about the latter). English and Russian are both understood.

  • Comments. After an edit succeeds, the comment lines it added are checked for ones that tell the history of the change: "Added retry logic", "I've removed…", "as requested", "used to return null", "until now…", "Добавил…", "Исправлено:", "по просьбе…". Claude gets a short note right then to rephrase those lines to describe the code as it is now, keeping the why. At most one note per file and three per session; the edit is never refused. Only files in common languages are checked (see syntaxOf in plugins/anti-slop/hooks/slop.ts). Quoted text, descriptions of what code does ("Updated by cron", "Token used to call the API", "Добавлено в v2 API"), tool directives, prose files and /tmp or scratchpad files are left alone.
  • Too many comments is opt-in (comments: density): three or more short one-line captions right above code, at 30% or more of the code lines the edit added.
  • Phrases. By default "Хороший вопрос", "Отличный вопрос", "You're absolutely right" or "You are absolutely right" where it starts a sentence is dropped from the reply as the conversation stores it (the copy Claude reads back on later turns), so Claude does not keep echoing itself; a sentence that needs the phrase ("You're absolutely right that the cache is stale") is kept. A short system-prompt section asks Claude to avoid all listed phrases in replies, code, comments and commit messages, and not to open a reply by agreeing with or praising you; "Ты прав", "Вы правы" and "load-bearing" are only covered by that request. Code spans, fences and text that looks like JSON are never rewritten, and what already streamed to your screen may still show the original.
  • Changes made through Bash (sed -i, heredocs) are not seen; use it with native-edits, which sends those to Edit and Write.
  • /anti-slop shows the settings and what it caught, for this session and, approximately, for all sessions on this machine. Settings: comments (narration, density or off), minComments, commentPercent, beforeNow (also count Russian "раньше…" / "теперь…" history comments; off because many teams write these on purpose), phrases (phrase=>replacement, phrase=> to drop an opener, a bare phrase to only avoid), rewrite.

Replayed against my own Claude Code transcripts (one developer, about four weeks; 2,816 successful Edit and Write calls in that extract): 0.1 would have sent 141 notes; of 30 picked at random, none pointed at comments that only restate the code, 3 at a history sentence inside a longer explanation, and the rest at explanations of why, file headers and section markers. 0.2 sends 3 (4 before the caps), each for an "until now…" or "it used to…" sentence inside a long explanatory comment; no "// Added X" style comment turned up outside /tmp. The phrase rewrite changed 15 openers in 29,966 stored text blocks. Treat the comment rule as a light guard, not a cleaner.


native-edits

In auto mode (Claude Code deciding permissions itself) and with bypassed permissions, Claude Code tells the model, in so many words, to "make small, mechanical file changes with sed, heredocs, or short scripts instead of the dedicated Read, Edit, or Write tools". So it does: on my machines about half of all local file changes went through Bash. /rewind cannot undo those changes (#87575), they skip the nested CLAUDE.md and path rules that Edit and Write load (#90450), and any hook or mod that checks Edit and Write never sees them. Related requests to keep Claude on its own file tools: #19649, #87971, #88041. Claude Code does show a diff of what a Bash command changed, but only in auto and bypass mode and only inside a git repository.

  • The cause. The auto-mode note is rewritten before the model reads it: reading and searching through Bash (cat, sed -n, grep) stays, file changes go to Edit (or Write for a new file). Claude Code adds that note in auto mode and with bypassed permissions, not in every session; in the same sessions it drops the "Edit files: Use Edit (NOT sed/awk)" lines from the Bash tool's description, and the mod puts an equivalent line back. Only the wording 2.1.289–2.1.294 uses is replaced; another build's note is left exactly as it is, and the guard below still applies.
  • The guard. A Bash command that writes a file in your project is not run. Claude is told which files and to use Edit or Write instead; you are never asked. It catches sed -i, perl -i, ruby -i, awk -i inplace and yq -i (every target, also through find -exec, xargs and loops), cat > f <<EOF, echo/printf redirections, tee, x > f.tmp && mv f.tmp f when x is text Claude wrote or a filter of f itself, writes inside shell functions, and Python, Node or Deno snippets that rewrite a file or write literal text (open(p, 'w'), writeFileSync), including a script written to /tmp and run in the same command. Shell variables in those snippets are filled in as the shell would (open('$S/x', 'w') with S=/tmp/… is a scratch file).
  • When Claude has no Edit or Write tool (--disallowedTools Edit Write, a headless run with Bash only), the write runs: a refusal would leave no way to make the change. A subagent's tools are not visible to the mod, so a subagent is refused once per command and told that the same command runs on a second try.
  • What it leaves alone: reading and searching; /tmp, $TMPDIR and the session scratchpad; /dev/null and 2>&1; files outside the project (setting scope); anything run on another machine (ssh, scp, rsync, docker exec); output of programs (git diff > x.patch, npm test > out.txt, curl … > page.html, curl … | jq . > f.tmp && mv f.tmp f, a script dumping computed JSON); content only known at run time (echo "$(date)" >> f, an unquoted heredoc with $VAR); paths only known at run time (xargs -I{}, an unset variable); and allowPaths (default **/*.log,/node_modules/,/dist/,/build/).
  • A bulk change (sed -i over 15 files) is refused like any other: Claude then makes it with Edit. Use warn or allowPaths if you would rather let those through.
  • /native-edits shows how many writes were refused, warned or let through, whether the auto-mode note was rewritten, and any allowPaths glob it could not read (an invalid glob is ignored, the rest still apply). Settings: mode (deny, warn runs the command and tells Claude afterwards, off), scope (project or everywhere), allowPaths, rewritePrompt.

Checked in a live session: asked to "change red to blue in file.txt using sed -i", Haiku's sed -i was refused, it read the file and made the change with Edit. With Edit and Write disallowed, the same sed -i ran. A python3 - <<EOF writing to $T/probe.txt with T set to a scratch directory ran. In auto mode, Opus quoted the rewritten note back instead of the original.

Replayed against all 20,197 Bash commands in my own transcripts (an earlier extract: 429 sessions and subagents): 2,090 commands would have been refused, writing 3,079 files, against 2,721 Edit and Write calls in the same sessions. Of 60 random refusals checked by hand, 59 were plain edits or new files Claude wrote through the shell and one was borderline (a manifest rewritten with computed hashes). Of 40 random commands that look like writes but were let through, none was a missed edit: they ran on another machine, saved a program's output, or wrote nothing. Writes to a path only known at run time (62 files, about 2% of the 3,079) are let through by design.


Security

What each mod reads and changes, and how to report a problem: SECURITY.md.

Development

./scripts/check.sh   # claude plugin validate + tsc + claude plugin test (may make one short claude -p call to refresh the mods switch)

MIT licensed.

Source 3 files
hooks/register.ts 162 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { denyReason, judge, listFiles, mentionsShellEdits, retryNote, rewriteAutoMode, settingsOf, TEMP_DIRS, warnNote, withEditLines } from './policy'
4import type { Settings } from './policy'
5import { findWrites } from './shell'
6
7const STATS = 'stats'
8const NOTE = 'note'
9const DESCRIBED = 'described'
10
11type Stats = { refused: number; warned: number; passed: number; rewritten: number; recent: string[] }
12type NoteState = 'rewritten' | 'unrecognized' | 'absent'
13type Counter = 'refused' | 'warned' | 'passed' | 'rewritten'
14/** Whether the loop that made a call has another way to change files: Edit or Write. */
15type Reach = 'yes' | 'no' | 'unknown'
16
17let settings: Settings = settingsOf({})
18let env: { home: string | undefined; temp: string[] } | null = null
19const session = { refused: 0, warned: 0, passed: 0 }
20/** Refusals that may have left an agent without a way forward: the same write again runs. */
21const refusedOnce = new Set<string>()
22
23async function environment($: EngineInterface) {
24  if (env === null) {
25    const [home, tmp] = await Promise.all([$.env.get('HOME'), $.env.get('TMPDIR')])
26    env = { home: home || undefined, temp: tmp ? [...TEMP_DIRS, tmp] : [...TEMP_DIRS] }
27  }
28  return env
29}
30
31async function stats($: EngineInterface): Promise<Stats> {
32  return { refused: 0, warned: 0, passed: 0, rewritten: 0, recent: [], ...((await $.store.get(STATS)) as Partial<Stats> | undefined) }
33}
34
35async function count($: EngineInterface, key: Counter, recent?: string) {
36  const s = await stats($)
37  await $.store.set(STATS, { ...s, [key]: s[key] + 1, recent: recent === undefined ? s.recent : [...s.recent, recent].slice(-5) })
38}
39
40/**
41 * The main loop's tools are known: without Edit and Write a refusal leaves it
42 * no way to make the change. A subagent's are not (its type may lack them).
43 */
44async function editReach($: EngineInterface, agentId: string | undefined): Promise<Reach> {
45  if (agentId !== undefined) return 'unknown'
46  try {
47    const names = (await $.tool.list()).map(t => t.name)
48    return names.includes('Edit') || names.includes('Write') ? 'yes' : 'no'
49  } catch {
50    return 'unknown'
51  }
52}
53
54export const register: Register = (on, options) => {
55  settings = settingsOf(options)
56  env = null
57  session.refused = 0
58  session.warned = 0
59  session.passed = 0
60  refusedOnce.clear()
61
62  on('session.start', async ($, e, next) => {
63    await $.command.register({ name: 'native-edits', description: 'native-edits: shell writes refused or warned, and whether the auto-mode note was rewritten', immediate: true })
64    return next(e)
65  })
66
67  // The cause: auto mode tells the model to make file changes with sed and heredocs.
68  // Reading and searching through the shell stays; changes go to Edit and Write.
69  if (settings.rewritePrompt) {
70    on('prompt.attachment', { type: 'auto_mode' }, async ($, e, next) => {
71      if (e.origin.kind !== 'engine') return next(e)
72      const out = rewriteAutoMode(e.text, settings.mode)
73      const state: NoteState = out.changed ? 'rewritten' : mentionsShellEdits(e.text) ? 'unrecognized' : 'absent'
74      await $.store.set(NOTE, { session: await $.session.id(), state })
75      if (out.changed) await count($, 'rewritten')
76      return next(out.changed ? { ...e, text: out.text } : e)
77    }).catch(($, e, next) => next(e))
78
79    // In the same sessions the Bash tool's description loses its "Edit files: Use Edit" lines.
80    on('tool.describe', { tool: 'Bash' }, async ($, e, next) => {
81      const out = await next(e)
82      const { description, added } = withEditLines(out.description)
83      await $.store.set(DESCRIBED, { session: await $.session.id(), added })
84      return added ? { ...out, description } : out
85    }).catch(($, e, next) => next(e))
86  }
87
88  // The guard: a Bash command that writes a project file is refused (or noted).
89  if (settings.mode !== 'off') {
90    on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
91      const [cwd, root, { home, temp }] = await Promise.all([$.session.cwd(), $.session.root(), environment($)])
92      const writes = findWrites(e.command, { cwd, home })
93      if (writes.length === 0) return next(e)
94      const roots = [...new Set([root, cwd])]
95      const { refuse } = judge(writes, { roots, temp }, settings)
96      if (refuse.length === 0) return next(e)
97      const summary = `${listFiles(refuse, roots)} (${[...new Set(refuse.map(w => w.via))].join(', ')})`
98      const reach = await editReach($, e.agentId)
99      if (reach === 'no') {
100        session.passed++
101        await count($, 'passed')
102        return next(e)
103      }
104      if (settings.mode === 'deny') {
105        const key = `${e.agentId ?? ''}\u0000${refuse.map(w => w.path ?? w.target).sort().join('\u0000')}`
106        if (reach === 'unknown' && refusedOnce.has(key)) {
107          const ran = await next(e)
108          if (ran.deny !== undefined || ran.isError === true) return ran
109          session.warned++
110          await count($, 'warned', summary)
111          return { ...ran, context: [...(ran.context ?? []), retryNote(refuse, roots)] }
112        }
113        if (reach === 'unknown') refusedOnce.add(key)
114        session.refused++
115        await count($, 'refused', summary)
116        return { deny: denyReason(refuse, roots, reach === 'unknown') }
117      }
118      const ran = await next(e)
119      if (ran.deny !== undefined || ran.isError === true) return ran
120      session.warned++
121      await count($, 'warned', summary)
122      return { ...ran, context: [...(ran.context ?? []), warnNote(refuse, roots)] }
123    }).catch(($, e, next) => next(e))
124  }
125
126  on('command.run', { command: 'native-edits' }, async $ => {
127    const s = await stats($)
128    const id = await $.session.id()
129    const note = (await $.store.get(NOTE)) as { session?: string; state?: NoteState } | undefined
130    const described = (await $.store.get(DESCRIBED)) as { session?: string; added?: boolean } | undefined
131    const here = note !== undefined && note.session === id ? note.state : undefined
132    const noteLine = !settings.rewritePrompt
133      ? 'Auto-mode note: left as is (rewritePrompt is off).'
134      : here === 'rewritten'
135        ? `Auto-mode note: rewritten — reading through Bash stays, file changes go to Edit and Write (${s.rewritten} time${s.rewritten === 1 ? '' : 's'} in all).`
136        : here === 'unrecognized'
137          ? 'Auto-mode note: its wording is not the one this version knows, so it was left as is; the guard still applies.'
138          : here === 'absent'
139            ? 'Auto-mode note: seen, with no shell-edit advice in it; nothing to rewrite.'
140            : 'Auto-mode note: not seen in this session yet (Claude Code adds it in auto mode and with bypass permissions).'
141    const describedLine =
142      !settings.rewritePrompt || described === undefined || described.session !== id
143        ? []
144        : [described.added ? 'Bash tool description: the lines sending edits to Edit and Write were missing and are added.' : 'Bash tool description: already sends edits to Edit and Write.']
145    const guard =
146      settings.mode === 'off'
147        ? 'Guard: off.'
148        : `Guard: ${settings.mode}, scope ${settings.scope}; always allowed: ${settings.allow.join(', ') || 'nothing'}, /tmp and $TMPDIR.`
149    return {
150      text: [
151        guard,
152        ...(settings.invalid.length > 0 ? [`Ignored invalid allowPaths glob${settings.invalid.length === 1 ? '' : 's'}: ${settings.invalid.join(', ')}.`] : []),
153        `Shell writes refused: ${session.refused} this session, ${s.refused} in all. Warned: ${session.warned} this session, ${s.warned} in all.`,
154        ...(s.passed > 0 ? [`Let through because Claude had no Edit or Write tool: ${session.passed} this session, ${s.passed} in all.`] : []),
155        noteLine,
156        ...describedLine,
157        ...(s.recent.length > 0 ? [`Last: ${s.recent.slice().reverse().join('; ')}.`] : []),
158      ].join('\n'),
159    }
160  })
161}
162
hooks/policy.ts 295 lines
1// What to do with the writes shell.ts finds, and the auto-mode note. Pure: no `$`.
2
3import type { PluginOptions } from 'claude-code'
4
5import type { Write } from './shell'
6
7export const DEFAULT_ALLOW = '**/*.log,**/node_modules/**,**/dist/**,**/build/**'
8
9export type Settings = {
10  mode: 'deny' | 'warn' | 'off'
11  scope: 'project' | 'everywhere'
12  allow: string[]
13  /** allowPaths entries that are no valid glob (`[z-a]`): left out, and named by /native-edits. */
14  invalid: string[]
15  rewritePrompt: boolean
16}
17
18export function settingsOf(options: PluginOptions): Settings {
19  const globs = (typeof options.allowPaths === 'string' ? options.allowPaths : DEFAULT_ALLOW)
20    .split(',')
21    .map(g => g.trim())
22    .filter(g => g !== '')
23  const allow: string[] = []
24  const invalid: string[] = []
25  for (const g of globs) {
26    try {
27      globToRegExp(g)
28      allow.push(g)
29    } catch {
30      invalid.push(g)
31    }
32  }
33  return {
34    mode: options.mode === 'warn' || options.mode === 'off' ? options.mode : 'deny',
35    scope: options.scope === 'everywhere' ? 'everywhere' : 'project',
36    allow,
37    invalid,
38    rewritePrompt: options.rewritePrompt !== false,
39  }
40}
41
42// ---------------------------------------------------------------- globs
43
44/** A glob as a RegExp: `**` any depth, `*` and `?` within a segment, `{a,b}` alternatives. */
45export function globToRegExp(glob: string): RegExp {
46  let re = ''
47  for (let i = 0; i < glob.length; i++) {
48    const c = glob[i] as string
49    if (c === '*' && glob[i + 1] === '*') {
50      const slashAfter = glob[i + 2] === '/'
51      re += slashAfter ? '(?:.*/)?' : '.*'
52      i += slashAfter ? 2 : 1
53    } else if (c === '*') re += '[^/]*'
54    else if (c === '?') re += '[^/]'
55    else if (c === '{') {
56      const end = glob.indexOf('}', i)
57      if (end < 0) re += '\\{'
58      else {
59        re += `(?:${glob
60          .slice(i + 1, end)
61          .split(',')
62          .map(a => a.replace(/[.+^${}()|[\]\\*?]/g, '\\$&'))
63          .join('|')})`
64        i = end
65      }
66    } else if (c === '[') {
67      const end = glob.indexOf(']', i + 1)
68      if (end < 0) re += '\\['
69      else {
70        re += `[${glob.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\')}]`
71        i = end
72      }
73    } else re += c.replace(/[.+^${}()|\\]/g, '\\$&')
74  }
75  // `dir/**` also matches `dir` itself.
76  return new RegExp(`^${re.replace(/\/\.\*$/, '(?:/.*)?')}$`)
77}
78
79/** Matches a path against a glob: a glob without a slash matches the file name, like .gitignore. An invalid glob matches nothing. */
80export function matchesGlob(path: string, glob: string, roots: readonly string[] = []): boolean {
81  let re: RegExp
82  try {
83    re = globToRegExp(glob)
84  } catch {
85    return false
86  }
87  if (!glob.includes('/')) return re.test(path.split('/').pop() ?? '')
88  if (re.test(path)) return true
89  for (const root of roots) {
90    const prefix = root === '/' ? '/' : `${root}/`
91    if (path.startsWith(prefix) && re.test(path.slice(prefix.length))) return true
92  }
93  return false
94}
95
96// ---------------------------------------------------------------- judging
97
98export type Place = {
99  /** The session's project root, then the shell's current directory: a write inside either is the project's. */
100  roots: string[]
101  /** Scratch space: /tmp, $TMPDIR. */
102  temp: string[]
103}
104
105export type Skipped = 'device' | 'temp' | 'outside' | 'allowed' | 'unresolved' | 'copy'
106
107export type Verdict = { refuse: Write[]; skipped: { write: Write; why: Skipped }[] }
108
109const DEVICE = /^\/(dev|proc|sys)(\/|$)/
110export const TEMP_DIRS = ['/tmp', '/var/tmp', '/private/tmp', '/private/var/folders', '/var/folders', '/dev/shm']
111
112function under(path: string, dir: string): boolean {
113  const d = dir.replace(/\/+$/, '')
114  return d === '' || path === d || path.startsWith(`${d}/`)
115}
116
117/** The fixed part of a path that may hold a glob: `/w/src/*.ts` → `/w/src`. */
118export function staticPrefix(path: string): string {
119  const at = path.search(/[*?[{]/)
120  if (at < 0) return path
121  const cut = path.slice(0, at)
122  return cut.slice(0, Math.max(cut.lastIndexOf('/'), 0)) || '/'
123}
124
125export function judge(writes: readonly Write[], place: Place, settings: Pick<Settings, 'scope' | 'allow'>): Verdict {
126  const verdict: Verdict = { refuse: [], skipped: [] }
127  for (const write of writes) {
128    const why = skipReason(write, place, settings)
129    if (why === null) verdict.refuse.push(write)
130    else verdict.skipped.push({ write, why })
131  }
132  // A copy of a file this command already changes is no second change to name.
133  const refused = new Set(verdict.refuse.map(w => w.path))
134  for (const copy of verdict.refuse.filter(w => w.from !== undefined && refused.has(w.from))) {
135    verdict.refuse.splice(verdict.refuse.indexOf(copy), 1)
136    verdict.skipped.push({ write: copy, why: 'copy' })
137  }
138  return verdict
139}
140
141function skipReason(write: Write, place: Place, settings: Pick<Settings, 'scope' | 'allow'>): Skipped | null {
142  if (write.path === null) return 'unresolved'
143  const fixed = staticPrefix(write.path)
144  if (DEVICE.test(fixed)) return 'device'
145  // Scratch space, unless the project itself lives inside it (a session started in /tmp/demo);
146  // a shell that merely cd'd into /tmp/work is still in scratch space.
147  const temp = place.temp.find(t => under(fixed, t))
148  const root = place.roots[0]
149  if (temp !== undefined && !(root !== undefined && under(fixed, root) && under(root, temp) && !under(temp, root))) return 'temp'
150  if (settings.scope === 'project' && !place.roots.some(r => under(fixed, r))) return 'outside'
151  if (settings.allow.some(g => matchesGlob(write.path as string, g, place.roots))) return 'allowed'
152  return null
153}
154
155// ---------------------------------------------------------------- messages
156
157const VIA: Record<Write['via'], string> = {
158  'sed -i': 'sed -i',
159  'perl -i': 'perl -i',
160  'ruby -i': 'ruby -i',
161  'awk -i inplace': 'awk -i inplace',
162  'yq -i': 'yq -i',
163  redirect: 'a redirection',
164  tee: 'tee',
165  sponge: 'sponge',
166  script: 'a script',
167  mv: 'a temporary file moved over it',
168  cp: 'a file copied over it',
169}
170
171/** `a.ts and b.ts`, `a.ts, b.ts and 3 more`, relative to the first root when inside it. */
172export function listFiles(writes: readonly Write[], roots: readonly string[] = []): string {
173  const names = [...new Set(writes.map(w => shortPath(w, roots)))]
174  const shown = names.slice(0, 3)
175  const more = names.length - shown.length
176  if (more > 0) return `${shown.join(', ')} and ${more} more`
177  if (shown.length <= 1) return shown.join('')
178  return `${shown.slice(0, -1).join(', ')} and ${shown[shown.length - 1]}`
179}
180
181/** A path as the model knows it: relative to the project; for `dir/**` (find, xargs), the command's own words. */
182function shortPath(write: Write, roots: readonly string[]): string {
183  const path = write.path
184  if (path === null || path.endsWith('/**')) return write.target
185  return relative(path, roots)
186}
187
188function relative(path: string, roots: readonly string[]): string {
189  for (const root of roots) if (root !== '/' && path.startsWith(`${root}/`)) return path.slice(root.length + 1)
190  return path
191}
192
193function vias(writes: readonly Write[], roots: readonly string[]): string {
194  return [...new Set(writes.map(w => (w.via === 'cp' && w.from !== undefined ? `${VIA.cp} from ${relative(w.from, roots)}` : VIA[w.via])))].join(', ')
195}
196
197/**
198 * The refusal. `retry`: the agent may have no Edit or Write tool (a subagent
199 * whose tools the plugin cannot see), so the same command runs on a second try.
200 */
201export function denyReason(writes: readonly Write[], roots: readonly string[], retry = false): string {
202  return (
203    `native-edits: not run. This command changes ${listFiles(writes, roots)} through the shell (${vias(writes, roots)}). ` +
204    'Make file changes with the Edit tool, or Write for a new file or a full rewrite, so they show as a diff, ' +
205    'can be undone with /rewind and follow the rules for that path. Run the rest of the command without the write if you still need it. ' +
206    'Reading and searching through Bash is fine, and so are files under /tmp, logs and build output.' +
207    (retry ? ' If you have no Edit or Write tool, run the same command again: it will run.' : '')
208  )
209}
210
211export function warnNote(writes: readonly Write[], roots: readonly string[]): string {
212  return (
213    `native-edits: this command changed ${listFiles(writes, roots)} through the shell (${vias(writes, roots)}); /rewind cannot undo that, and checks on Edit and Write never saw it. ` +
214    'Make further changes to files with the Edit tool (Write for new files).'
215  )
216}
217
218/** After a refusal the agent ran the same write again: it may have no Edit or Write tool. */
219export function retryNote(writes: readonly Write[], roots: readonly string[]): string {
220  return `native-edits: run on the second try; it changed ${listFiles(writes, roots)} through the shell. If you have the Edit and Write tools, make further file changes with them.`
221}
222
223// ---------------------------------------------------------------- the auto-mode note
224
225/**
226 * The sentences the auto-mode attachment (`auto_mode`) uses to send file
227 * changes through the shell, as Claude Code 2.1.289-2.1.294 words them: the
228 * strict variant and the relaxed one. Tool names are captured, not assumed.
229 */
230const STRICT =
231  /Do your work through the (\S+) tool wherever it can accomplish the job: read files with cat, head, or sed -n, search with grep and find, and make file changes with sed, heredocs, or short scripts, rather than using the dedicated (\S+), (\S+), or (\S+) tools\. Fall back to a dedicated tool only when \S+ genuinely cannot do the job\./
232const RELAXED =
233  /You can do much of your work through the (\S+) tool when it is the simpler route: read files with cat, head, or sed -n, search with grep and find, and make small, mechanical file changes with sed, heredocs, or short scripts instead of the dedicated (\S+), (\S+), or (\S+) tools\. The choice is yours: prefer \S+ or \S+ when a shell edit would be fragile, such as exact or multi-line replacements, or sed\/awk flags that differ between GNU and BSD\/macOS\./
234
235function changeRule(edit: string, write: string, shell: string, mode: Settings['mode']): string {
236  // The guard watches the Bash tool only.
237  const enforced =
238    mode === 'deny' && shell === 'Bash'
239      ? ` A ${shell} command that writes to a project file is refused by the native-edits plugin the user installed, in every permission mode.`
240      : ''
241  return (
242    `Make every file change with the ${edit} tool (${write} for a new file or a full rewrite), never with sed -i, heredocs, redirection, tee or scripts, ` +
243    `so each change shows as a diff, can be undone with /rewind and follows the rules for that path.${enforced} Scratch files under /tmp are fine.`
244  )
245}
246
247export type Rewrite = { text: string; changed: boolean }
248
249/** Keeps reading and searching through the shell; sends file changes to Edit and Write. */
250export function rewriteAutoMode(text: string, mode: Settings['mode']): Rewrite {
251  const strict = STRICT.exec(text)
252  if (strict !== null) {
253    const [, shell, read, edit, write] = strict as unknown as [string, string, string, string, string]
254    const replacement =
255      `Do your reading and searching through the ${shell} tool wherever it can accomplish the job: read files with cat, head, or sed -n, and search with grep and find, rather than using the dedicated ${read} tool. ` +
256      changeRule(edit, write, shell, mode)
257    return { text: text.replace(STRICT, () => replacement), changed: true }
258  }
259  const relaxed = RELAXED.exec(text)
260  if (relaxed !== null) {
261    const [, shell, read, edit, write] = relaxed as unknown as [string, string, string, string, string]
262    const replacement =
263      `You can do much of your reading and searching through the ${shell} tool when it is the simpler route: read files with cat, head, or sed -n, and search with grep and find, instead of the dedicated ${read} tool. ` +
264      changeRule(edit, write, shell, mode)
265    return { text: text.replace(RELAXED, () => replacement), changed: true }
266  }
267  return { text, changed: false }
268}
269
270/** Whether the note speaks of shell file changes at all (another build's wording). */
271export function mentionsShellEdits(text: string): boolean {
272  return /file changes with sed|heredocs/.test(text)
273}
274
275// ---------------------------------------------------------------- the Bash tool's description
276
277/**
278 * Where the auto-mode note goes shell-first, Claude Code also drops from the
279 * Bash tool's description the lines that send edits to Edit and Write
280 * ("Edit files: Use Edit (NOT sed/awk)"). Puts them back, before the
281 * description's `# Instructions` when it has one.
282 */
283export const EDIT_LINES =
284  'File changes: when you have the Edit and Write tools, change a file with Edit (NOT sed -i, awk or perl -i) and create or rewrite one with Write (NOT echo >, cat <<EOF or tee). ' +
285  'Reading and searching through this tool (cat, head, sed -n, grep, find) is fine.'
286
287export type Described = { description: string; added: boolean }
288
289export function withEditLines(description: string): Described {
290  if (/Edit files: Use/i.test(description) || description.includes(EDIT_LINES)) return { description, added: false }
291  const at = description.indexOf('\n# Instructions')
292  if (at < 0) return { description: `${description.replace(/\s+$/, '')}\n\n${EDIT_LINES}`, added: true }
293  return { description: `${description.slice(0, at).replace(/\s+$/, '')}\n\n${EDIT_LINES}\n${description.slice(at)}`, added: true }
294}
295
hooks/shell.ts 1714 lines
1// Finds the local files a shell command writes. Pure: no `$`, no I/O, never throws on odd input.
2
3// ---------------------------------------------------------------- syntax
4
5/** A piece of a word: literal text, a variable (`$NAME`, `${NAME:-def}`), a leading `~`, or a value only known at run time. */
6type Part = { lit: string } | { v: string; def?: string } | { tilde: string } | { dyn: string }
7
8export type Word = {
9  /** The value with quotes removed; variables kept as `$NAME`. */
10  text: string
11  parts: Part[]
12  /** Holds a command, arithmetic or other substitution whose value cannot be known. */
13  dynamic: boolean
14  quoted: boolean
15  /** Command and process substitutions inside the word, parsed. */
16  subs: List[]
17}
18
19export type Redirect = {
20  fd: number | null
21  op: string
22  target: Word | null
23  /** A here-document's body, or a here-string's word. */
24  body?: string
25  verbatim?: boolean
26  /** A here-string's word, for what the program reads once the shell filled it in. */
27  word?: Word
28}
29
30export type Command =
31  | { kind: 'simple'; words: Word[]; redirects: Redirect[] }
32  | { kind: 'group'; body: List; subshell: boolean; redirects: Redirect[] }
33
34export type Pipeline = Command[]
35export type AndOr = Pipeline[]
36export type List = AndOr[]
37
38const META = new Set([' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>'])
39const REDIRECT = /^(\d{0,2})(&>>|&>|>>|>\||>&|>|<<<|<<-|<<|<&|<>|<)/
40const MAX_DEPTH = 6
41/** Nested groups and subshells deeper than this are not read (a stack, not a real command). */
42const MAX_NEST = 64
43/** `name()` or `function name`: a function definition, whose body is read as a group. */
44const FUNCTION_HEAD = /^(?:function[ \t]+[^\s(){};&|<>'"`$]+(?:[ \t]*\([ \t]*\))?|[A-Za-z_][\w.:-]*[ \t]*\([ \t]*\))[ \t]*/
45
46/** Where a nested list ends: a subshell's `)`, a group's `}`, or the end of input. */
47type Stop = ')' | '}' | null
48
49class Parser {
50  i = 0
51  private nest = 0
52  private pending: { redirect: Redirect; delim: string; strip: boolean }[] = []
53
54  constructor(
55    private readonly s: string,
56    private readonly depth = 0,
57  ) {}
58
59  parse(): List {
60    const list = this.list(null)
61    this.readHeredocs()
62    return list
63  }
64
65  private get eof() {
66    return this.i >= this.s.length
67  }
68
69  private at(k = 0): string {
70    return this.s[this.i + k] ?? ''
71  }
72
73  private blanks() {
74    for (;;) {
75      const c = this.at()
76      if (c === ' ' || c === '\t' || c === '\r') this.i++
77      else if (c === '\\' && this.at(1) === '\n') this.i += 2
78      else return
79    }
80  }
81
82  private newline() {
83    this.i++
84    this.readHeredocs()
85  }
86
87  private blanksAndNewlines() {
88    for (;;) {
89      this.blanks()
90      if (this.at() === '\n') this.newline()
91      else if (this.at() === '#') this.comment()
92      else return
93    }
94  }
95
96  private comment() {
97    while (!this.eof && this.at() !== '\n') this.i++
98  }
99
100  private reserved(word: string): boolean {
101    if (!this.s.startsWith(word, this.i)) return false
102    const after = this.s[this.i + word.length]
103    return after === undefined || META.has(after)
104  }
105
106  private readHeredocs() {
107    const pending = this.pending
108    this.pending = []
109    for (const p of pending) {
110      const lines: string[] = []
111      while (!this.eof) {
112        let end = this.s.indexOf('\n', this.i)
113        if (end < 0) end = this.s.length
114        const line = this.s.slice(this.i, end).replace(/\r$/, '')
115        this.i = Math.min(end + 1, this.s.length)
116        if ((p.strip ? line.replace(/^\t+/, '') : line) === p.delim) break
117        lines.push(p.strip ? line.replace(/^\t+/, '') : line)
118      }
119      p.redirect.body = lines.join('\n')
120    }
121  }
122
123  private list(stop: Stop): List {
124    const list: List = []
125    for (;;) {
126      this.separators(stop)
127      if (this.eof) break
128      if (stop === ')' && this.at() === ')') break
129      if (stop === '}' && this.reserved('}')) break
130      const before = this.i
131      const ao = this.andOr(stop)
132      if (ao.length > 0) list.push(ao)
133      if (this.i === before) this.i++
134    }
135    return list
136  }
137
138  private separators(stop: Stop) {
139    for (;;) {
140      this.blanks()
141      const c = this.at()
142      if (c === '\n') this.newline()
143      else if (c === '#') this.comment()
144      else if (c === ';') this.i++
145      else if (c === '&' && this.at(1) !== '>') this.i++
146      else if (c === '|') this.i++
147      else if (c === ')' && stop !== ')') this.i++
148      else return
149    }
150  }
151
152  private andOr(stop: Stop): AndOr {
153    const ao: AndOr = []
154    for (;;) {
155      const p = this.pipeline(stop)
156      if (p.length > 0) ao.push(p)
157      this.blanks()
158      if (this.s.startsWith('&&', this.i) || this.s.startsWith('||', this.i)) {
159        this.i += 2
160        this.blanksAndNewlines()
161        continue
162      }
163      return ao
164    }
165  }
166
167  private pipeline(stop: Stop): Pipeline {
168    const p: Pipeline = []
169    for (;;) {
170      this.blanks()
171      if (this.at() === '!' && (this.at(1) === ' ' || this.at(1) === '\t')) this.i++
172      const cmd = this.command(stop)
173      if (cmd !== null) p.push(cmd)
174      this.blanks()
175      if (this.at() === '|' && this.at(1) !== '|') {
176        this.i += this.at(1) === '&' ? 2 : 1
177        this.blanksAndNewlines()
178        continue
179      }
180      return p
181    }
182  }
183
184  private command(stop: Stop): Command | null {
185    this.blanks()
186    if (this.at() === '(' && this.at(1) === '(') {
187      this.skipBalanced('(', ')')
188      return { kind: 'simple', words: [], redirects: this.trailingRedirects() }
189    }
190    const opens = this.at() === '(' || this.reserved('{')
191    if (opens && this.nest >= MAX_NEST) {
192      this.i = this.s.length
193      return null
194    }
195    if (this.at() === '(') {
196      this.i++
197      this.nest++
198      const body = this.depth < MAX_DEPTH ? this.list(')') : this.skipTo(')')
199      this.nest--
200      if (this.at() === ')') this.i++
201      return { kind: 'group', body, subshell: true, redirects: this.trailingRedirects() }
202    }
203    if (this.reserved('{')) {
204      this.i++
205      this.nest++
206      const body = this.list('}')
207      this.nest--
208      if (this.reserved('}')) this.i++
209      return { kind: 'group', body, subshell: false, redirects: this.trailingRedirects() }
210    }
211    // `g() { echo x > f; }`: the body is read as if the function ran.
212    const fn = FUNCTION_HEAD.exec(this.s.slice(this.i, this.i + 256))
213    if (fn !== null) {
214      const at = this.i
215      this.i += fn[0].length
216      this.blanksAndNewlines()
217      if (this.at() === '(' || this.reserved('{')) return this.command(stop)
218      this.i = at
219    }
220    return this.simple(stop)
221  }
222
223  private skipTo(close: string): List {
224    while (!this.eof && this.at() !== close) this.i++
225    return []
226  }
227
228  private skipBalanced(open: string, close: string) {
229    let depth = 0
230    while (!this.eof) {
231      const c = this.at()
232      if (c === open) depth++
233      else if (c === close) {
234        depth--
235        if (depth <= 0) {
236          this.i++
237          return
238        }
239      } else if (c === "'" || c === '"') {
240        const end = this.s.indexOf(c, this.i + 1)
241        this.i = end < 0 ? this.s.length : end
242      }
243      this.i++
244    }
245  }
246
247  private trailingRedirects(): Redirect[] {
248    const out: Redirect[] = []
249    for (;;) {
250      this.blanks()
251      const r = this.redirect()
252      if (r === null) return out
253      out.push(r)
254    }
255  }
256
257  private simple(stop: Stop): Command | null {
258    const words: Word[] = []
259    const redirects: Redirect[] = []
260    let inTest = false
261    for (;;) {
262      this.blanks()
263      if (this.eof) break
264      const c = this.at()
265      if (c === '\n' || c === ';' || c === '|' || c === ')') break
266      if (c === '&' && this.at(1) !== '>') break
267      if (c === '#') {
268        this.comment()
269        break
270      }
271      if (stop === '}' && words.length === 0 && this.reserved('}')) break
272      if (inTest && (c === '<' || c === '>')) {
273        this.i++
274        words.push(literal(c))
275        continue
276      }
277      const r = this.redirect()
278      if (r !== null) {
279        redirects.push(r)
280        continue
281      }
282      if (c === '(') {
283        // `f() {`, `a=(1 2)`: nothing a write hides in.
284        if (this.at(1) === ')') this.i += 2
285        else this.skipBalanced('(', ')')
286        continue
287      }
288      const before = this.i
289      const w = this.word()
290      if (this.i === before) {
291        this.i++
292        continue
293      }
294      words.push(w)
295      if (words.length === 1 && w.text === '[[' && !w.quoted) inTest = true
296      else if (inTest && w.text === ']]') inTest = false
297    }
298    if (words.length === 0 && redirects.length === 0) return null
299    return { kind: 'simple', words, redirects }
300  }
301
302  private redirect(): Redirect | null {
303    const m = REDIRECT.exec(this.s.slice(this.i, this.i + 6))
304    if (m === null) return null
305    const op = m[2] as string
306    if ((op === '<' || op === '>') && this.at(m[0].length) === '(') return null
307    this.i += m[0].length
308    const fd = m[1] === '' ? null : Number(m[1])
309    this.blanks()
310    if (op === '<<' || op === '<<-') {
311      const delim = this.word()
312      const redirect: Redirect = { fd, op, target: null, body: '', verbatim: delim.quoted }
313      if (delim.text !== '' || delim.quoted) this.pending.push({ redirect, delim: delim.text, strip: op === '<<-' })
314      return redirect
315    }
316    const target = this.word()
317    if (op === '<<<') return { fd, op, target: null, body: target.text, verbatim: !target.dynamic && target.parts.every(p => 'lit' in p), word: target }
318    if (target.text === '' && !target.quoted && target.subs.length === 0) return { fd, op, target: null }
319    if ((op === '>&' || op === '<&') && /^(\d+-?|-)$/.test(target.text)) return { fd, op, target }
320    return { fd, op: op === '>&' ? '&>' : op, target }
321  }
322
323  private word(): Word {
324    const w: Word = { text: '', parts: [], dynamic: false, quoted: false, subs: [] }
325    const c0 = this.at()
326    if ((c0 === '<' || c0 === '>') && this.at(1) === '(') {
327      this.i += 2
328      w.subs.push(this.sub(')'))
329      if (this.at() === ')') this.i++
330      w.dynamic = true
331      w.parts.push({ dyn: `${c0}(…)` })
332      return w
333    }
334    if (c0 === '~') {
335      const m = /^~([A-Za-z0-9_.-]*)/.exec(this.s.slice(this.i, this.i + 64))
336      const user = m?.[1] ?? ''
337      w.parts.push({ tilde: user })
338      w.text += `~${user}`
339      this.i += 1 + user.length
340    }
341    while (!this.eof) {
342      const c = this.at()
343      if (META.has(c)) break
344      if (c === '\\') {
345        if (this.at(1) !== '\n') {
346          add(w, this.at(1))
347          w.quoted = true
348        }
349        this.i += 2
350      } else if (c === "'") {
351        let end = this.s.indexOf("'", this.i + 1)
352        if (end < 0) end = this.s.length
353        add(w, this.s.slice(this.i + 1, end))
354        w.quoted = true
355        this.i = end + 1
356      } else if (c === '"') {
357        this.i++
358        this.double(w)
359        w.quoted = true
360      } else if (c === '$') {
361        this.dollar(w, false)
362      } else if (c === '`') {
363        this.backtick(w)
364      } else {
365        add(w, c)
366        this.i++
367      }
368    }
369    return w
370  }
371
372  private double(w: Word) {
373    while (!this.eof) {
374      const c = this.at()
375      if (c === '"') {
376        this.i++
377        return
378      }
379      if (c === '\\') {
380        const n = this.at(1)
381        if (n === '\n') {
382          this.i += 2
383          continue
384        }
385        add(w, '$`"\\'.includes(n) && n !== '' ? n : `\\${n}`)
386        this.i += 2
387      } else if (c === '$') {
388        this.dollar(w, true)
389      } else if (c === '`') {
390        this.backtick(w)
391      } else {
392        add(w, c)
393        this.i++
394      }
395    }
396  }
397
398  private dollar(w: Word, inDouble: boolean) {
399    const n = this.at(1)
400    if (n === '(' && this.at(2) === '(') {
401      this.i++
402      this.skipBalanced('(', ')')
403      dynamic(w, '$((…))')
404      return
405    }
406    if (n === '(') {
407      this.i += 2
408      w.subs.push(this.sub(')'))
409      if (this.at() === ')') this.i++
410      dynamic(w, '$(…)')
411      return
412    }
413    if (n === '{') {
414      const start = this.i + 2
415      let depth = 1
416      let j = start
417      while (j < this.s.length && depth > 0) {
418        const ch = this.s[j]
419        if (ch === '{') depth++
420        else if (ch === '}') depth--
421        j++
422      }
423      const inner = this.s.slice(start, j - 1)
424      this.i = j
425      const m = /^([A-Za-z_]\w*)(?::?-(.*))?$/.exec(inner)
426      if (m !== null) {
427        w.parts.push(m[2] === undefined ? { v: m[1] as string } : { v: m[1] as string, def: m[2] })
428        w.text += `\${${inner}}`
429      } else dynamic(w, `\${${inner}}`)
430      return
431    }
432    if (n === "'" && !inDouble) {
433      let j = this.i + 2
434      let out = ''
435      while (j < this.s.length && this.s[j] !== "'") {
436        if (this.s[j] === '\\' && j + 1 < this.s.length) {
437          const e = this.s[j + 1] as string
438          out += e === 'n' ? '\n' : e === 't' ? '\t' : e
439          j += 2
440        } else out += this.s[j++]
441      }
442      add(w, out)
443      w.quoted = true
444      this.i = j + 1
445      return
446    }
447    if (n === '"' && !inDouble) {
448      this.i += 2
449      this.double(w)
450      w.quoted = true
451      return
452    }
453    const m = /^(?:[A-Za-z_]\w*|[0-9@*#?$!-])/.exec(this.s.slice(this.i + 1, this.i + 65))
454    if (m !== null) {
455      w.parts.push({ v: m[0] })
456      w.text += `$${m[0]}`
457      this.i += 1 + m[0].length
458      return
459    }
460    add(w, '$')
461    this.i++
462  }
463
464  private backtick(w: Word) {
465    let j = this.i + 1
466    let inner = ''
467    while (j < this.s.length && this.s[j] !== '`') {
468      if (this.s[j] === '\\' && j + 1 < this.s.length) {
469        const e = this.s[j + 1] as string
470        inner += '`$\\'.includes(e) ? e : `\\${e}`
471        j += 2
472      } else inner += this.s[j++]
473    }
474    this.i = j + 1
475    if (this.depth < MAX_DEPTH) w.subs.push(new Parser(inner, this.depth + 1).parse())
476    dynamic(w, '`…`')
477  }
478
479  private sub(close: ')'): List {
480    if (this.depth >= MAX_DEPTH) {
481      this.skipBalanced('(', ')')
482      this.i--
483      return []
484    }
485    const inner = new Parser(this.s, this.depth + 1)
486    inner.i = this.i
487    const list = inner.list(close)
488    this.i = inner.i
489    return list
490  }
491}
492
493function add(w: Word, text: string) {
494  const last = w.parts[w.parts.length - 1]
495  if (last !== undefined && 'lit' in last) last.lit += text
496  else w.parts.push({ lit: text })
497  w.text += text
498}
499
500function dynamic(w: Word, text: string) {
501  w.parts.push({ dyn: text })
502  w.dynamic = true
503  w.text += text
504}
505
506function literal(text: string): Word {
507  return { text, parts: [{ lit: text }], dynamic: false, quoted: false, subs: [] }
508}
509
510/** Parses a shell command line into lists, pipelines and simple commands. */
511export function parseShell(source: string): List {
512  try {
513    return new Parser(source).parse()
514  } catch {
515    return []
516  }
517}
518
519// ---------------------------------------------------------------- paths
520
521/** Joins and normalizes a POSIX path against a base directory. */
522export function resolvePath(path: string, base: string): string {
523  const joined = path.startsWith('/') ? path : `${base.replace(/\/+$/, '')}/${path}`
524  const out: string[] = []
525  for (const seg of joined.split('/')) {
526    if (seg === '' || seg === '.') continue
527    if (seg === '..') out.pop()
528    else out.push(seg)
529  }
530  return `/${out.join('/')}`
531}
532
533
534// ---------------------------------------------------------------- writes
535
536export type WriteVia = 'sed -i' | 'perl -i' | 'ruby -i' | 'awk -i inplace' | 'yq -i' | 'redirect' | 'tee' | 'sponge' | 'script' | 'mv' | 'cp'
537
538export type Write = {
539  /** The target as the command names it. */
540  target: string
541  /** Absolute path (a glob when the command names a pattern), or null when a variable or substitution decides it. */
542  path: string | null
543  via: WriteVia
544  /** The command that wrote it: `echo`, `cat`, `python3`... */
545  by: string
546  /** For `cp`: the file copied, which this command wrote first. */
547  from?: string
548}
549
550export type ShellContext = { cwd: string; home?: string }
551
552type Found = Omit<Write, 'by' | 'from'> & {
553  authored: boolean
554  producer: string
555  from?: string
556  /** The files the producing command read (its operands): a filter that read the file it is moved over edits it. */
557  reads?: string[]
558}
559
560type State = {
561  cwd: string | null
562  vars: Map<string, string>
563  home: string | undefined
564  found: Found[]
565  /** Files this command wrote from a literal here-document, by path: a script it may run next. */
566  scripts: Map<string, string>
567  depth: number
568  /** Stand-ins another program replaces at run time (`{}` of find and xargs, `xargs -I X`): a path holding one is unknown. */
569  placeholders: string[]
570}
571
572/** Stands in code for a value the shell fills in that cannot be known here; a path holding it is unknown. */
573const UNKNOWN = '\u0000'
574
575/** Prints content the model wrote itself (not another program's output). */
576const AUTHORS = new Set(['echo', 'printf', ':', 'true'])
577/** Pass their input through, transformed: authored in, authored out. */
578const FILTERS = new Set([
579  'cat', 'tr', 'sed', 'awk', 'gawk', 'mawk', 'jq', 'yq', 'sort', 'uniq', 'head', 'tail', 'base64', 'envsubst', 'column',
580  'cut', 'fold', 'fmt', 'iconv', 'rev', 'nl', 'expand', 'unexpand', 'xxd', 'tee', 'dos2unix', 'unix2dos', 'perl', 'grep',
581])
582/** Rewrite another file's content: their output moved over a file is an edit of it. */
583const TRANSFORMERS = new Set([
584  'sed', 'awk', 'gawk', 'mawk', 'perl', 'jq', 'yq', 'grep', 'sort', 'uniq', 'tr', 'cut', 'head', 'tail', 'cat',
585  'column', 'iconv', 'dos2unix', 'unix2dos', 'expand', 'unexpand',
586])
587const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'ash'])
588const PYTHON = /^(python[0-9.]*|pypy3?)$/
589const JS = /^(node|nodejs|bun|tsx|ts-node)$/
590
591/** Every local file the command writes with content the model authored or rewrote. */
592export function findWrites(command: string, ctx: ShellContext): Write[] {
593  const st: State = { cwd: ctx.cwd, vars: new Map(), home: ctx.home, found: [], scripts: new Map(), depth: 0, placeholders: ['{}'] }
594  walkList(parseShell(command), st)
595  const seen = new Set<string>()
596  const out: Write[] = []
597  for (const f of st.found) {
598    if (!f.authored) continue
599    const key = f.path ?? `?${f.target}`
600    if (seen.has(key)) continue
601    seen.add(key)
602    out.push({ target: f.target, path: f.path, via: f.via, by: f.producer, ...(f.from === undefined ? {} : { from: f.from }) })
603  }
604  return out
605}
606
607function fork(st: State): State {
608  return { ...st, vars: new Map(st.vars) }
609}
610
611function walkList(list: List, st: State) {
612  for (const ao of list) for (const p of ao) walkPipeline(p, st)
613}
614
615/** What a command passes down a pipe: whether it prints text the model wrote, and the files it read. */
616type Piped = { authored: boolean; reads: string[]; listed: Listed | null }
617
618function walkPipeline(p: Pipeline, st: State) {
619  let up: Piped = { authored: false, reads: [], listed: null }
620  for (const cmd of p) {
621    if (cmd.kind === 'group') {
622      const inner = cmd.subshell ? fork(st) : st
623      walkList(cmd.body, inner)
624      const authored = groupAuthors(cmd.body, inner)
625      const redirected = redirectOutputs(cmd.redirects, authored, 'group', st)
626      up = { authored: authored && !redirected, reads: [], listed: null }
627      continue
628    }
629    const listed = listedBy(cmd, st)
630    up = { ...walkSimple(cmd, st, up, p.length > 1), listed }
631  }
632}
633
634/** The file names a command prints, for `| xargs sed -i`: each a path or a `dir/**` pattern. */
635type Listed = { paths: string[]; target: string }
636
637function listedBy(cmd: Extract<Command, { kind: 'simple' }>, st: State): Listed | null {
638  const argv = unwrap(cmd.words).argv
639  const name = nameOf(argv[0])
640  if (name === null || st.cwd === null) return null
641  const cwd = st.cwd
642  const under = (dir: string) => `${dir === '/' ? '' : dir}/**`
643  const resolve = (words: Word[]) => {
644    const values = words.map(w => expand(w, st))
645    return values.some(v => v === null) ? null : (values as string[]).map(v => resolvePath(v, cwd))
646  }
647  if (name === 'find' || name === 'fd' || name === 'fdfind') {
648    const roots: Word[] = []
649    if (name === 'find') for (const w of argv.slice(1)) if (w.text.startsWith('-') || w.text === '(' || w.text === '!') break; else roots.push(w)
650    else roots.push(...operands(argv).slice(1))
651    const dirs = roots.length === 0 ? [cwd] : resolve(roots)
652    return dirs === null ? null : { paths: dirs.map(under), target: `files ${name} lists under ${roots.map(w => w.text).join(', ') || '.'}` }
653  }
654  if (name === 'grep' || name === 'egrep' || name === 'rg' || name === 'ag') {
655    const flags = argv.slice(1).filter(w => w.text.startsWith('-')).map(w => w.text)
656    const lists = name === 'rg' && flags.includes('--files')
657    if (!lists && !flags.some(f => /^-[a-zA-Z]*[lL]|^--files-with(out)?-match/.test(f))) return null
658    const pattern = flags.some(f => /^-[a-zA-Z]*[ef]$|^--regexp|^--file/.test(f)) || lists ? 0 : 1
659    const where = operands(argv).filter(w => !(w.text.startsWith('-') && w.text !== '-')).slice(pattern)
660    const recursive = name !== 'grep' && name !== 'egrep' ? true : flags.some(f => /^-[a-zA-Z]*[rR]|^--recursive|^--dereference-recursive/.test(f))
661    if (where.length === 0) return recursive ? { paths: [under(cwd)], target: `files ${name} finds` } : null
662    const paths = resolve(where)
663    return paths === null ? null : { paths: recursive ? paths.map(under) : paths, target: `files ${name} finds in ${where.map(w => w.text).join(', ')}` }
664  }
665  if (name === 'ls' || name === 'echo') {
666    const words = operands(argv)
667    if (words.length === 0) return name === 'ls' ? { paths: [`${cwd === '/' ? '' : cwd}/*`], target: 'files ls lists' } : null
668    const paths = resolve(words)
669    return paths === null ? null : { paths, target: words.map(w => w.text).join(' ') }
670  }
671  if (name === 'git' && /^(ls-files|grep|diff)$/.test(argv[1]?.text ?? '')) {
672    const sub = argv[1]?.text
673    if (sub === 'grep' && !argv.some(w => /^-[a-zA-Z]*l|^--name-only|^--files-with-matches/.test(w.text))) return null
674    if (sub === 'diff' && !argv.some(w => w.text === '--name-only')) return null
675    return { paths: [under(cwd)], target: `files git ${sub} lists` }
676  }
677  return null
678}
679
680/** A `{ ...; } > f` group prints only what the model wrote when every command in it does. */
681function groupAuthors(list: List, st: State): boolean {
682  let any = false
683  for (const ao of list)
684    for (const p of ao)
685      for (const cmd of p) {
686        if (cmd.kind === 'group') {
687          if (!groupAuthors(cmd.body, st)) return false
688          any = true
689          continue
690        }
691        const argv = unwrap(cmd.words).argv
692        const name = nameOf(argv[0])
693        if (name === null) continue
694        const input = stdinOf(cmd.redirects, st)
695        const prints = AUTHORS.has(name) ? literalWords(argv.slice(1), st) : name === 'cat' && operands(argv).length === 0 && input?.literal === true
696        if (!prints) return false
697        any = true
698      }
699  return any
700}
701
702function isAssignment(w: Word): boolean {
703  const first = w.parts[0]
704  return first !== undefined && 'lit' in first && /^[A-Za-z_]\w*=/.test(first.lit)
705}
706
707function nameOf(w: Word | undefined): string | null {
708  if (w === undefined || w.dynamic) return null
709  const base = w.text.split('/').pop() ?? ''
710  return base === '' ? null : base
711}
712
713function variable(name: string, st: State): string | undefined {
714  return st.vars.get(name) ?? (name === 'HOME' ? st.home : name === 'PWD' && st.cwd !== null ? st.cwd : undefined)
715}
716
717/** A word's value, or null when the shell fills in something that cannot be known here. */
718function expand(w: Word, st: State): string | null {
719  if (w.dynamic) return null
720  const out = shellText(w, st)
721  return out.includes(UNKNOWN) ? null : out
722}
723
724/**
725 * A word as the program it is passed to reads it (a `-c` or `-e` script):
726 * known variables filled in, anything else marked UNKNOWN. Single-quoted text
727 * stays as written, as the shell leaves it.
728 */
729function shellText(w: Word, st: State): string {
730  let out = ''
731  for (const p of w.parts) {
732    if ('lit' in p) out += p.lit
733    else if ('tilde' in p) out += p.tilde === '' && st.home !== undefined ? st.home : UNKNOWN
734    else if ('dyn' in p) out += UNKNOWN
735    else out += variable(p.v, st) ?? p.def ?? UNKNOWN
736  }
737  return out
738}
739
740/**
741 * An unquoted here-document's body as the program reads it: `\$`, `` \` `` and
742 * `\\` unescaped, known variables filled in, other expansions marked UNKNOWN.
743 * `literal`: nothing in it expands, so it reads exactly as the model wrote it.
744 */
745function expandBody(body: string, st: State): { text: string; literal: boolean } {
746  let text = ''
747  let literal = true
748  for (let j = 0; j < body.length; j++) {
749    const c = body[j] as string
750    const n = body[j + 1] ?? ''
751    if (c === '\\') {
752      if (n === '$' || n === '`' || n === '\\') {
753        text += n
754        j++
755      } else if (n === '\n') j++
756      else text += c
757      continue
758    }
759    if (c === '`') {
760      const end = body.indexOf('`', j + 1)
761      j = end < 0 ? body.length : end
762      text += UNKNOWN
763      literal = false
764      continue
765    }
766    if (c !== '$') {
767      text += c
768      continue
769    }
770    if (n === '(') {
771      let depth = 0
772      let k = j + 1
773      for (; k < body.length; k++) {
774        if (body[k] === '(') depth++
775        else if (body[k] === ')' && --depth === 0) break
776      }
777      j = k
778      text += UNKNOWN
779      literal = false
780      continue
781    }
782    if (n === '{') {
783      const end = body.indexOf('}', j + 2)
784      const inner = body.slice(j + 2, end < 0 ? body.length : end)
785      j = end < 0 ? body.length : end
786      const m = /^([A-Za-z_]\w*)(?::?-(.*))?$/.exec(inner)
787      text += (m === null ? undefined : (variable(m[1] as string, st) ?? m[2])) ?? UNKNOWN
788      literal = false
789      continue
790    }
791    const m = /^(?:[A-Za-z_]\w*|[0-9@*#?$!-])/.exec(body.slice(j + 1, j + 65))
792    if (m === null) {
793      text += c
794      continue
795    }
796    text += variable(m[0], st) ?? UNKNOWN
797    j += m[0].length
798    literal = false
799  }
800  return { text, literal }
801}
802
803/** Forgets writes in place: substitutions and subshells share the list. */
804function drop(st: State, gone: (f: Found) => boolean) {
805  for (let k = st.found.length - 1; k >= 0; k--) if (gone(st.found[k] as Found)) st.found.splice(k, 1)
806}
807
808function record(st: State, target: string, via: WriteVia, authored: boolean, producer: string, word?: Word, reads?: string[]): string | null {
809  const path = pathOf(word === undefined ? target : expand(word, st), st)
810  st.found.push({ target, path, via, authored, producer, ...(reads === undefined ? {} : { reads }) })
811  return path
812}
813
814function pathOf(value: string | null, st: State): string | null {
815  if (value === null || value === '' || value.includes(UNKNOWN)) return null
816  if (st.placeholders.some(p => value.includes(p))) return null
817  if (value.startsWith('/')) return resolvePath(value, '/')
818  return st.cwd === null ? null : resolvePath(value, st.cwd)
819}
820
821/** The text of a script file the command wrote a moment ago, when it runs it. */
822function scriptFile(w: Word | undefined, st: State): string | null {
823  if (w === undefined) return null
824  const path = pathOf(expand(w, st), st)
825  return path === null ? null : (st.scripts.get(path) ?? null)
826}
827
828const WRITE_OPS = new Set(['>', '>>', '>|', '&>', '&>>'])
829
830/**
831 * Records output redirections; true when stdout goes to a file. `content`: what
832 * is written, when known; `reads`: the files the command read.
833 */
834function redirectOutputs(redirects: Redirect[], authored: boolean, producer: string, st: State, content?: string, reads?: string[]): boolean {
835  let stdout = false
836  for (const r of redirects) {
837    if (r.target !== null) for (const l of r.target.subs) walkList(l, fork(st))
838    if (!WRITE_OPS.has(r.op) || r.target === null) continue
839    const isStdout = r.fd === null || r.fd === 1 || r.op.startsWith('&')
840    if (isStdout) stdout = true
841    const path = record(st, r.target.text, 'redirect', authored && isStdout, producer, r.target, isStdout ? reads : undefined)
842    if (path !== null && isStdout && content !== undefined && r.op !== '>>' && r.op !== '&>>') st.scripts.set(path, content)
843  }
844  return stdout
845}
846
847/**
848 * A here-document or here-string. `body`: as written (a shell reading it fills
849 * in its own variables); `code`: as a program reads it, the shell's expansions
850 * done; `literal`: the shell leaves it exactly as written.
851 */
852type Input = { body: string; code: string; literal: boolean }
853
854function stdinOf(redirects: Redirect[], st: State): Input | null {
855  for (const r of redirects) {
856    if (r.op !== '<<' && r.op !== '<<-' && r.op !== '<<<') continue
857    const body = r.body ?? ''
858    if (r.op === '<<<') return { body, code: r.word === undefined ? body : shellText(r.word, st), literal: r.verbatim === true }
859    if (r.verbatim === true) return { body, code: body, literal: true }
860    const { text, literal } = expandBody(body, st)
861    return { body, code: text, literal }
862  }
863  return null
864}
865
866/** Words the shell does not fill in at run time: the model knows exactly what they print. */
867function literalWords(words: Word[], st: State): boolean {
868  return words.every(w => !w.dynamic && expand(w, st) !== null)
869}
870
871/** `replace`: the string `xargs -I` replaces with each input line. */
872type Unwrapped = { argv: Word[]; xargs: boolean; replace?: string }
873
874const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', '!', '{', '}', 'esac'])
875const DECLARES = new Set(['export', 'local', 'declare', 'typeset', 'readonly'])
876
877const SUDO_ARGS = new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '--user', '--group', '--chdir'])
878/** xargs options that take the next word; GNU `-i`, `-e` and `-l` take theirs joined, if at all. */
879const XARGS_ARGS = new Set(['-I', '-n', '-P', '-d', '-L', '-s', '-a', '-E', '--max-args', '--max-procs', '--delimiter', '--arg-file', '--max-chars'])
880
881/** Strips the commands that only run another one: sudo, env, nice, timeout, xargs... */
882function unwrap(words: Word[]): Unwrapped {
883  let argv = words
884  let xargs = false
885  let replace: string | undefined
886  for (let guard = 0; guard < 8; guard++) {
887    while (argv.length > 0 && isAssignment(argv[0] as Word)) argv = argv.slice(1)
888    const name = nameOf(argv[0])
889    if (name === null) break
890    let k = 1
891    const opt = () => argv[k]?.text ?? ''
892    if (KEYWORDS.has(name) && !(argv[0] as Word).quoted) {
893      // `if`, `then`, `do`, `!`...: the command follows.
894    } else if (name === 'sudo' || name === 'doas' || name === 'runuser') {
895      while (k < argv.length && opt().startsWith('-')) {
896        if (opt() === '--') {
897          k++
898          break
899        }
900        k += SUDO_ARGS.has(opt()) ? 2 : 1
901      }
902    } else if (name === 'env') {
903      while (k < argv.length && (opt().startsWith('-') || /^[A-Za-z_]\w*=/.test(opt()))) k += opt() === '-u' || opt() === '-C' ? 2 : 1
904    } else if (name === 'nice' || name === 'ionice') {
905      while (k < argv.length && opt().startsWith('-')) k += /^-[nc]$/.test(opt()) ? 2 : 1
906    } else if (name === 'timeout') {
907      while (k < argv.length && opt().startsWith('-')) k += /^-[sk]$/.test(opt()) ? 2 : 1
908      k++
909    } else if (name === 'stdbuf' || name === 'setsid' || name === 'nohup' || name === 'time' || name === 'builtin' || name === 'exec' || name === 'chronic' || name === 'unbuffer') {
910      while (k < argv.length && opt().startsWith('-')) k += /^-[ioe]$/.test(opt()) ? 2 : 1
911    } else if (name === 'command') {
912      if (/^-[vV]/.test(opt())) return { argv: [], xargs }
913      while (k < argv.length && opt().startsWith('-')) k++
914    } else if (name === 'xargs') {
915      while (k < argv.length && opt().startsWith('-')) {
916        const o = opt()
917        if (o === '-I') replace = argv[k + 1]?.text
918        else if (o === '-i' || o === '--replace') replace = '{}'
919        else if (/^-[Ii]./.test(o)) replace = o.slice(2)
920        else if (o.startsWith('--replace=')) replace = o.slice('--replace='.length)
921        k += XARGS_ARGS.has(o) ? 2 : 1
922      }
923      xargs = true
924    } else if ((name === 'uv' || name === 'poetry' || name === 'pipenv') && opt() === 'run') {
925      k = 2
926      while (k < argv.length && opt().startsWith('-')) k++
927    } else break
928    argv = argv.slice(k)
929  }
930  return { argv, xargs, ...(replace === undefined || replace === '' ? {} : { replace }) }
931}
932
933/** Arguments that are not options (a lone `-` counts as an operand). */
934function operands(argv: Word[]): Word[] {
935  const out: Word[] = []
936  let opts = true
937  for (const w of argv.slice(1)) {
938    if (opts && w.text === '--') {
939      opts = false
940      continue
941    }
942    if (opts && w.text.startsWith('-') && w.text !== '-') continue
943    out.push(w)
944  }
945  return out
946}
947
948function assign(words: Word[], st: State) {
949  for (const w of words) {
950    const value = expand(w, st)
951    const name = w.text.slice(0, w.text.indexOf('='))
952    if (value === null) st.vars.delete(name)
953    else st.vars.set(name, value.slice(value.indexOf('=') + 1))
954  }
955}
956
957/** Walks one simple command; returns what it passes down the pipe. */
958function walkSimple(cmd: Extract<Command, { kind: 'simple' }>, st: State, up: Piped, piped: boolean): Omit<Piped, 'listed'> {
959  const none = { authored: false, reads: [] }
960  for (const w of cmd.words) for (const l of w.subs) walkList(l, fork(st))
961
962  const words = cmd.words
963  // `for f in src/*.ts; do sed -i ... "$f"`: the loop variable stands for the list.
964  if (words[0]?.text === 'for' && words[2]?.text === 'in' && words[1] !== undefined) {
965    const items = words.slice(3).map(w => expand(w, st))
966    const name = words[1].text
967    if (items.length === 0 || items.some(v => v === null)) st.vars.delete(name)
968    else st.vars.set(name, items.length === 1 ? (items[0] as string) : `{${items.join(',')}}`)
969    return none
970  }
971  if (words.length > 0 && words.every(isAssignment)) assign(words, st)
972
973  const { argv, xargs, replace } = unwrap(words)
974  // `xargs -I F sh -c '... > F'`: F is whatever xargs reads, unknown here.
975  if (replace !== undefined) st = { ...st, placeholders: [...st.placeholders, replace] }
976  const name = nameOf(argv[0])
977  const input = stdinOf(cmd.redirects, st)
978  const stdin = input?.body ?? null
979  const code = input?.code ?? null
980  const fedContent = input !== null ? input.literal : up.authored
981
982  if (name === null) {
983    // A bare `> file` truncates or creates it.
984    const bare = words.length === 0 || words.every(isAssignment)
985    redirectOutputs(cmd.redirects, bare && cmd.redirects.some(r => r.op === '>' || r.op === '>|'), '', st)
986    return none
987  }
988  if (DECLARES.has(name) && !(argv[0] as Word).quoted) {
989    assign(argv.slice(1).filter(isAssignment), st)
990    return none
991  }
992
993  if (name === 'cd' || name === 'pushd') {
994    const dir = argv[1]
995    if (dir === undefined) st.cwd = st.home ?? null
996    else {
997      const value = dir.text === '-' ? null : expand(dir, st)
998      st.cwd = value === null ? null : st.cwd === null && !value.startsWith('/') ? null : resolvePath(value, st.cwd ?? '/')
999    }
1000    return none
1001  }
1002  if (name === 'popd') {
1003    st.cwd = null
1004    return none
1005  }
1006
1007  if (name === 'find') {
1008    walkFind(argv, st)
1009    redirectOutputs(cmd.redirects, false, name, st)
1010    return none
1011  }
1012
1013  let authored = false
1014  if (AUTHORS.has(name)) authored = literalWords(argv.slice(1), st)
1015  else if (name === 'cat') authored = operands(argv).every(w => w.text === '-') && (fedContent || (!piped && input === null))
1016  else if (FILTERS.has(name)) authored = fedContent
1017  // What a filter read, its own files and those piped into it: moved over one of them, its output is an edit.
1018  const reads = TRANSFORMERS.has(name) ? [...readsOf(argv, st), ...(input === null ? up.reads : [])] : []
1019
1020  if (name === 'sed') inPlace(argv, st, 'sed -i', xargs, up.listed)
1021  else if (name === 'perl') {
1022    inPlace(argv, st, 'perl -i', xargs, up.listed)
1023    const script = flagValue(argv, ['-e', '-E'], w => shellText(w, st))
1024    if (script !== null) for (const t of perlWrites(script)) recordScript(st, t, name)
1025  } else if (name === 'ruby') {
1026    inPlace(argv, st, 'ruby -i', xargs, up.listed)
1027    const script = flagValue(argv, ['-e'], w => shellText(w, st)) ?? (operands(argv).length === 0 ? code : null)
1028    if (script !== null) for (const t of rubyWrites(script)) recordScript(st, t, name)
1029  } else if (name === 'awk' || name === 'gawk') {
1030    awkInPlace(argv, st, xargs, up.listed)
1031  } else if (name === 'yq') {
1032    yqInPlace(argv, st, xargs, up.listed)
1033  } else if (name === 'tee') {
1034    for (const w of operands(argv)) {
1035      const path = record(st, w.text, 'tee', fedContent, name, w)
1036      if (path !== null && input?.literal === true && !argv.some(a => a.text === '-a' || a.text === '--append')) st.scripts.set(path, input.body)
1037    }
1038  } else if (name === 'sponge') {
1039    for (const w of operands(argv)) record(st, w.text, 'sponge', true, name, w)
1040  } else if (SHELLS.has(name)) {
1041    const script = shellScript(argv, stdin) ?? scriptFile(operands(argv)[0], st)
1042    if (script !== null && st.depth < MAX_DEPTH) walkList(parseShell(script), { ...fork(st), depth: st.depth + 1 })
1043  } else if (name === 'eval' && st.depth < MAX_DEPTH) {
1044    walkList(parseShell(argv.slice(1).map(w => w.text).join(' ')), { ...fork(st), depth: st.depth + 1 })
1045  } else if (name === 'su' || name === 'flock') {
1046    const script = flagValue(argv, ['-c', '--command'], w => w.text)
1047    if (script !== null && st.depth < MAX_DEPTH) walkList(parseShell(script), { ...fork(st), depth: st.depth + 1 })
1048  } else if (PYTHON.test(name)) {
1049    const file = operands(argv)[0]
1050    const written = scriptFile(file, st)
1051    const run = written !== null ? { code: written, args: operands(argv).map(w => expand(w, st)) } : pythonCode(argv, code, st)
1052    if (run !== null) for (const t of pythonWrites(run.code, run.args, st.home)) recordScript(st, t, name)
1053  } else if (JS.test(name) || name === 'deno') {
1054    const inline = jsCode(argv, code, name, st)
1055    const script = inline ?? scriptFile(operands(argv)[0], st)
1056    // `node -e CODE a b`: process.argv is [node, a, b]; `node file.js a`: [node, file.js, a].
1057    const rest = operands(argv).map(w => expand(w, st))
1058    const args = [name, ...(inline !== null && argv.some(w => /^(-e|--eval|-p|--print)$/.test(w.text)) ? rest.slice(1) : rest)]
1059    if (script !== null) for (const t of jsWrites(script, st.home, args)) recordScript(st, t, name)
1060  } else if (name === 'mv' || name === 'cp') {
1061    moveOver(argv, st, name)
1062  } else if (name === 'rm' || name === 'unlink') {
1063    for (const w of operands(argv)) {
1064      const v = expand(w, st)
1065      if (v === null || st.cwd === null) continue
1066      const path = resolvePath(v, st.cwd)
1067      drop(st, f => f.path !== null && (f.path === path || f.path.startsWith(`${path}/`)))
1068    }
1069  }
1070
1071  const redirected = redirectOutputs(cmd.redirects, authored, name, st, name === 'cat' && input?.literal === true ? input.body : undefined, reads)
1072  return { authored: authored && !redirected, reads: redirected ? [] : reads }
1073}
1074
1075/** The files a command names as operands, resolved (a pattern or a script among them is harmless). */
1076function readsOf(argv: Word[], st: State): string[] {
1077  const out: string[] = []
1078  for (const w of operands(argv)) {
1079    const path = pathOf(expand(w, st), st)
1080    if (path !== null) out.push(path)
1081  }
1082  return out
1083}
1084
1085function recordScript(st: State, write: ScriptWrite | string, producer: string) {
1086  const { path, edit } = typeof write === 'string' ? { path: write, edit: true } : write
1087  if (path === null || path.includes(UNKNOWN)) st.found.push({ target: '(a path the script computes)', path: null, via: 'script', authored: edit || path === null, producer })
1088  else record(st, path, 'script', edit, producer)
1089}
1090
1091type InPlaceVia = 'sed -i' | 'perl -i' | 'ruby -i'
1092
1093/**
1094 * How each tool spells its options: `script` letters take the program (next
1095 * word when last in a bundle), `arg` letters take the next word when last,
1096 * `stop` letters take the rest of the bundle; `bsd`: `-i ''` or `-i .bak` may follow.
1097 */
1098const FLAVORS: Record<InPlaceVia, { script: string; arg: string; stop: string; bsd: boolean }> = {
1099  'sed -i': { script: 'ef', arg: 'l', stop: '', bsd: true },
1100  'perl -i': { script: 'eE', arg: 'MIm', stop: 'FCdDx', bsd: false },
1101  'ruby -i': { script: 'e', arg: 'rICE', stop: 'FWxKT', bsd: false },
1102}
1103
1104/** `sed -i`, `perl -i`, `ruby -i`: every file operand is rewritten in place. */
1105function inPlace(argv: Word[], st: State, via: InPlaceVia, xargs: boolean, listed: Listed | null) {
1106  const flavor = FLAVORS[via]
1107  let inplace = false
1108  let script = false
1109  const files: Word[] = []
1110  let opts = true
1111  for (let k = 1; k < argv.length; k++) {
1112    const w = argv[k] as Word
1113    const t = w.text
1114    if (opts && t === '--') {
1115      opts = false
1116      continue
1117    }
1118    if (opts && t.startsWith('--')) {
1119      if (t === '--in-place' || t.startsWith('--in-place=')) inplace = true
1120      else if (t === '--expression' || t === '--file') {
1121        script = true
1122        k++
1123      } else if (t.startsWith('--expression=') || t.startsWith('--file=')) script = true
1124      else if (t === '--line-length') k++
1125      continue
1126    }
1127    if (opts && t.startsWith('-') && t.length > 1) {
1128      const letters = t.slice(1)
1129      for (let j = 0; j < letters.length; j++) {
1130        const ch = letters[j] as string
1131        const last = j === letters.length - 1
1132        if (ch === 'i') {
1133          inplace = true
1134          // GNU takes the rest as a suffix (`-i.bak`); BSD takes the next word (`-i ''`, `-i .bak`).
1135          const next = argv[k + 1]
1136          if (flavor.bsd && last && next !== undefined && (next.text === '' || /^\.[\w.~-]+$/.test(next.text)) && argv.length - k > 3) k++
1137          break
1138        }
1139        if (flavor.script.includes(ch)) {
1140          script = true
1141          if (last) k++
1142          break
1143        }
1144        if (flavor.arg.includes(ch)) {
1145          if (last) k++
1146          break
1147        }
1148        if (flavor.stop.includes(ch)) break
1149      }
1150      continue
1151    }
1152    if (!script) {
1153      script = true
1154      continue
1155    }
1156    files.push(w)
1157  }
1158  if (inplace) recordInPlace(files, st, via, xargs, listed)
1159}
1160
1161/** The files an in-place edit rewrites; with xargs and no file of its own, those the command before it lists. */
1162function recordInPlace(files: Word[], st: State, via: WriteVia, xargs: boolean, listed: Listed | null) {
1163  const held = (w: Word) => st.placeholders.some(p => w.text.includes(p))
1164  for (const w of files) if (!held(w)) record(st, w.text, via, true, via, w)
1165  if (!xargs || !files.every(held)) return
1166  if (listed === null) st.found.push({ target: '(files from xargs)', path: null, via, authored: true, producer: via })
1167  else for (const path of listed.paths) st.found.push({ target: listed.target, path, via, authored: true, producer: via })
1168}
1169
1170/** `gawk -i inplace '{...}' f`: the files after the program are rewritten. */
1171function awkInPlace(argv: Word[], st: State, xargs: boolean, listed: Listed | null) {
1172  let inplace = false
1173  let program = false
1174  const files: Word[] = []
1175  let opts = true
1176  for (let k = 1; k < argv.length; k++) {
1177    const w = argv[k] as Word
1178    const t = w.text
1179    if (opts && t === '--') {
1180      opts = false
1181      continue
1182    }
1183    if (opts && t.startsWith('-') && t !== '-') {
1184      const lib = t === '-i' || t === '--include' ? argv[++k]?.text : (/^-i(.+)$/.exec(t)?.[1] ?? /^--include=(.+)$/.exec(t)?.[1])
1185      if (lib !== undefined) {
1186        if (/^inplace(\.awk)?$/.test(lib)) inplace = true
1187      } else if (t === '-f' || t === '--file' || t === '-e' || t === '--source') {
1188        program = true
1189        k++
1190      } else if (/^--(file|source)=/.test(t)) program = true
1191      else if (t === '-v' || t === '-F' || t === '--assign' || t === '--field-separator') k++
1192      continue
1193    }
1194    if (!program) {
1195      program = true
1196      continue
1197    }
1198    if (/^[A-Za-z_]\w*=/.test(t)) continue
1199    files.push(w)
1200  }