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…

Unofficial — not made or endorsed by Anthropic.
Before/after examples, install steps and the measurements behind each plugin: padezhnov.com/en/tools.

| Mod | What it does |
|---|---|
| anti-slop | Flags 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-edits | File 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.
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.
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.comments: density): three or more short one-line captions right above code, at 30% or more of the code lines the edit added.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.
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.
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.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).--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./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/).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.
What each mod reads and changes, and how to report a problem: SECURITY.md.
./scripts/check.sh # claude plugin validate + tsc + claude plugin test (may make one short claude -p call to refresh the mods switch)
MIT licensed.
hooks/register.ts 162 lines1import 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}
162hooks/policy.ts 295 lines1// 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}
295hooks/shell.ts 1714 lines1// 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 }