Blocks Bash commands that write files (inline scripts, sed -i, redirects, tee) and points the model at Edit/Write instead

Function-hook plugins for Claude Code. Each one installs on its own.
At a Claude Code prompt, install any of the mods below:
/plugin install <mod> --marketplace daviddecorso/claude-mods
Answer y to add the marketplace, then pick a scope.
Blocks Bash commands that write files (inline Python/Node scripts, sed -i, perl -pi, redirects, tee) and points the model at Edit/Write instead, so every file change shows up as a reviewable diff. Writes to temp and scratchpad directories are allowed.
Option extraAllowed: a regex; Bash commands matching it are never blocked (e.g. ^node scripts/gen-).
/plugin install shell-edit-guard --marketplace daviddecorso/claude-mods
Finds each repo's dev servers (dev* package scripts and wrangler dev, for Vite, Astro, Next, Wrangler and Expo), gives them fixed ports, and runs them as named services. Adds a /dev pane and dev_list / dev_start / dev_stop / dev_restart / dev_logs tools, so the model reads filtered logs instead of backgrounding servers in Bash.
/plugin install dev-servers --marketplace daviddecorso/claude-mods
Hands a plans/<slug>.md plan to agy, the Antigravity CLI (headless with live progress, or copied for agy -i), or to Google's Jules (cloud PR), then starts a plan-vs-diff review when the work lands. When an agy run's review reports findings, they can go back to agy to fix (written to plans/fixes/<slug>.md), and that run gets reviewed too. Ships two skills: plan-handoff writes the plan with a recommended implementer, and plan-diff-review checks the result against it.
Needs agy on PATH for agy handoffs, and a Jules API key for Jules handoffs, stored in the macOS Keychain:
security add-generic-password -s jules-api -a "$USER" -w
(JULES_API_KEY in the environment also works.)
/plugin install handoff --marketplace daviddecorso/claude-mods
A pane for a per-repo prompt queue kept in <repo>/.queue/ (shared across worktrees): pick queued prompts, batch them into the prompt box, queue drafts, and archive items with the x hotkey or /aq done <seq…>. Also gives the model an aq_add tool to queue follow-ups. Pairs with the aq CLI from zsh-utils, which reads and writes the same queue.
/plugin install aq --marketplace daviddecorso/claude-mods
Run a mod from a working copy with claude --plugin-dir ./<mod>. Check it with claude plugin validate ./<mod> and claude plugin test ./<mod>.
Each mod's tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code generates the first time it loads the mod. On a fresh clone, run claude --plugin-dir ./<mod> once before type-checking.
MIT
hooks/register.ts 133 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { classify, denial } from './classify'
5import { afterBlock, type Outcome } from './outcome'
6
7type $ = EngineInterface
8
9const isOff = atom({ plugin: 'shell-edit-guard', key: 'isOff' } as const, false)
10const isAllowedOnce = atom({ plugin: 'shell-edit-guard', key: 'isAllowedOnce' } as const, false)
11const deniesThisTurn = atom({ plugin: 'shell-edit-guard', key: 'deniesThisTurn' } as const, 0)
12
13const RETRY_LIMIT = 3
14
15// The last block still waiting to see what the model does next; per loop, so a subagent's calls don't settle the main loop's.
16let pending: { targets: string[]; agentId: string | undefined } | undefined
17
18function compile(pattern: unknown): RegExp | undefined {
19 if (typeof pattern !== 'string' || pattern.trim() === '') return undefined
20 try {
21 return new RegExp(pattern)
22 } catch {
23 return undefined
24 }
25}
26
27// Appends one line to ~/.claude/mod-logs/shell-edit-guard.jsonl, which /mods-report reads; never fails the hook.
28async function logEvent($: $, event: Record<string, unknown>) {
29 try {
30 const home = await $.env.get('HOME')
31 const line = JSON.stringify({ at: new Date().toISOString(), session: await $.session.id(), ...event })
32 await $.process.run(
33 ['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', `${home}/.claude/mod-logs/shell-edit-guard.jsonl`],
34 { stdin: `${line}\n` },
35 )
36 } catch {
37 // Logging is best effort.
38 }
39}
40
41function settle($: $, outcome: Outcome, tool: string) {
42 pending = undefined
43 void logEvent($, { event: 'after-block', outcome, tool })
44}
45
46export const register: Register = (on, options) => {
47 const extraAllowed = compile(options.extraAllowed)
48
49 on('session.start', async ($, e, next) => {
50 await $.command.register({
51 name: 'edit-guard',
52 description: 'Shell-edit guard: off, on, allow-once, or status',
53 argumentHint: '[off|on|allow-once|status]',
54 })
55
56 return next(e)
57 })
58
59 on('command.run', { command: 'edit-guard' }, async ($, e) => {
60 const arg = e.args.trim()
61 if (arg === 'off' || arg === 'on' || arg === 'allow-once') void logEvent($, { event: 'command', arg })
62 if (arg === 'off') {
63 await update($, isOff, () => true)
64 return { text: 'Shell-edit guard is off for this session.' }
65 }
66 if (arg === 'on') {
67 await update($, isOff, () => false)
68 return { text: 'Shell-edit guard is on.' }
69 }
70 if (arg === 'allow-once') {
71 await update($, isAllowedOnce, () => true)
72 return {
73 text: 'The next shell file write will run once.',
74 context: ['The user ran /edit-guard allow-once: the next command that shell-edit-guard would block may run.'],
75 }
76 }
77
78 const state = (await read($, isOff)) ? 'off' : 'on'
79 return { text: `Shell-edit guard is ${state}. Usage: /edit-guard off | on | allow-once` }
80 })
81
82 on('turn.start', async ($, e, next) => {
83 await update($, deniesThisTurn, () => 0)
84 return next(e)
85 })
86
87 on('turn.complete', async ($, e, next) => {
88 if (pending && pending.agentId === e.agentId) settle($, 'turn-ended', '')
89 return next(e)
90 })
91
92 on('tool.call', async ($, e, next) => {
93 const command = e.tool === 'Bash' ? e.command : undefined
94 const isGuarding = command !== undefined && !(await read($, isOff))
95 const verdict = isGuarding ? classify(command, { cwd: await $.session.cwd(), extraAllowed }) : undefined
96 const isShellWrite = verdict?.kind === 'write'
97
98 if (pending && pending.agentId === e.agentId) {
99 const path = 'file_path' in e ? e.file_path : 'notebook_path' in e ? e.notebook_path : undefined
100 settle($, afterBlock(pending.targets, String(e.tool), path, isShellWrite), String(e.tool))
101 }
102
103 if (verdict?.kind !== 'write' || command === undefined) return next(e)
104
105 if (await read($, isAllowedOnce)) {
106 await update($, isAllowedOnce, () => false)
107 void logEvent($, { event: 'allowed-once', via: verdict.via, targets: verdict.targets, command: command.slice(0, 400) })
108 return next(e)
109 }
110
111 const denies = await update($, deniesThisTurn, n => n + 1)
112 const isCapped = denies > RETRY_LIMIT
113 pending = { targets: verdict.targets, agentId: e.agentId }
114 void logEvent($, {
115 event: 'block',
116 via: verdict.via,
117 targets: verdict.targets,
118 isBulk: verdict.isBulk,
119 isCapped,
120 cwd: await $.session.cwd(),
121 command: command.slice(0, 400),
122 })
123
124 if (isCapped) {
125 return {
126 deny: 'shell-edit-guard: blocked again. Stop retrying shell writes; tell the user what you are trying to change and ask how to proceed.',
127 }
128 }
129
130 return { deny: denial(verdict) }
131 }).catch(($, e, next) => next(e))
132}
133hooks/classify.ts 251 lines1import { assignments, commandWords, expand, parse } from './shell'
2
3export type Via = 'interpreter' | 'in-place' | 'redirect' | 'tee'
4
5export type Verdict =
6 | { kind: 'allow' }
7 | { kind: 'write'; via: Via; targets: string[]; isBulk: boolean }
8
9export type Context = {
10 cwd: string
11 extraAllowed?: RegExp
12}
13
14type Found = { via: Via; targets: string[]; isBulk: boolean }
15
16const TEMP_PREFIXES = ['/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/', '/dev/']
17
18const INTERPRETERS = /^(python(3(\.\d+)?)?|node|bun|deno|ruby|perl|tsx|ts-node)$/
19const CODE_FLAGS = new Set(['-c', '-e', '-E', '--eval', '-p', '--print', 'eval'])
20
21const WRITE_APIS = [
22 /(?<![.\w$])open\s*\([^)]*,\s*(f?['"])(w|a|x|r\+|wb|ab|w\+|a\+)\1/,
23 /(?<![.\w$])open\s*\([^)]*\bmode\s*=\s*['"](w|a|x)/,
24 /\.write_(text|bytes)\s*\(/,
25 /\b(writeFile|appendFile)(Sync)?\s*\(/,
26 /\bfs\.(promises\.)?(write|truncate|copyFile|rename)/,
27 /\bcreateWriteStream\s*\(/,
28 /\bFile\.(write|open\([^)]*['"]w)/,
29 /\bBun\.write\s*\(/,
30 /\bDeno\.write(Text)?File/,
31 /\bopen\s*\(?\s*(my\s+)?\$?\w+\s*,\s*['"]\+?>/,
32 /\bshutil\.(copy|move)/,
33 /\bos\.(rename|replace)\s*\(/,
34]
35
36const BULK_HINTS = /\b(os\.walk|\.rglob|\.glob\(|glob\.glob|readdirSync|readdir\(|globSync|fast-glob)/
37
38// Commands whose redirected output is a generated artifact rather than a hand edit (codegen, package scripts).
39const ALLOWED_WRITERS =
40 /^(prettier|biome|eslint|oxlint|dprint|stylelint|git|gh|tsc|vitest|jest|playwright|turbo|nx|wrangler|supabase|jscodeshift|ast-grep|sg|npm|pnpm|yarn|bun|npx|bunx|pnpx|corepack)$/
41
42export function isTempPath(path: string): boolean {
43 return path === '/dev/null' || TEMP_PREFIXES.some(prefix => path.startsWith(prefix))
44}
45
46function resolve(path: string, cwd: string): string {
47 if (path.startsWith('/') || path.startsWith('~') || path.startsWith('$')) return path
48 const out: string[] = []
49 for (const part of `${cwd}/${path}`.split('/')) {
50 if (part === '..') out.pop()
51 else if (part !== '.' && part !== '') out.push(part)
52 }
53
54 return `/${out.join('/')}`
55}
56
57function display(path: string, cwd: string): string {
58 return path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
59}
60
61function interpreterTargets(source: string): string[] {
62 const literal = `(['"\`])([^'"\`\\n]+)\\1`
63 const targets = new Set<string>()
64
65 for (const pattern of [
66 new RegExp(`(?<![.\\w$])open\\s*\\(\\s*f?${literal}`, 'g'),
67 new RegExp(`\\b(?:writeFile|appendFile)(?:Sync)?\\s*\\(\\s*${literal}`, 'g'),
68 new RegExp(`\\b(?:Bun\\.write|Deno\\.writeTextFile|Deno\\.writeFile|createWriteStream)\\s*\\(\\s*${literal}`, 'g'),
69 new RegExp(`\\bPath\\s*\\(\\s*${literal}\\s*\\)\\s*\\.write_`, 'g'),
70 ]) {
71 for (const match of source.matchAll(pattern)) targets.add(match.at(-1)!)
72 }
73
74 const variables = new Map<string, string>()
75 for (const match of source.matchAll(
76 /\b(?:const|let|var|my)?\s*\$?([A-Za-z_]\w*)\s*=\s*(?:Path\s*\(\s*|path\.(?:join|resolve)\s*\(\s*)?(['"`])([^'"`\n]+)\2/g,
77 )) {
78 variables.set(match[1]!, match[3]!)
79 }
80 for (const [name, path] of variables) {
81 const uses = new RegExp(
82 `\\bopen\\s*\\(\\s*\\$?${name}\\b|\\b${name}\\.write_|\\b(?:writeFile|appendFile)(?:Sync)?\\s*\\(\\s*${name}\\b|\\bBun\\.write\\s*\\(\\s*${name}\\b`,
83 )
84 if (uses.test(source)) targets.add(path)
85 }
86
87 return [...targets]
88}
89
90// The code an interpreter runs inline (`-c`/`-e` argument or a heredoc on stdin); undefined when it runs a script file.
91function inlineSource(words: string[], heredocs: string[]): string | undefined {
92 const name = (words[0] ?? '').split('/').at(-1) ?? ''
93 if (!INTERPRETERS.test(name)) return undefined
94
95 const parts: string[] = []
96 let script: string | undefined
97 for (let i = 1; i < words.length; i++) {
98 const w = words[i]!
99 if (CODE_FLAGS.has(w)) {
100 parts.push(words[i + 1] ?? '')
101 i += 1
102 } else if (w === '-m' || w === '-' || !w.startsWith('-')) {
103 script = w
104 break
105 }
106 }
107 if (parts.length === 0 && heredocs.length > 0 && (script === undefined || script === '-')) parts.push(...heredocs)
108
109 return parts.length > 0 ? parts.join('\n') : undefined
110}
111
112// The file operands of `sed -i` / `perl -pi`: everything after the script that is not a flag.
113function inPlaceOperands(words: string[], scriptFlag: RegExp): string[] {
114 const operands: string[] = []
115 let hasScript = words.some(w => scriptFlag.test(w))
116 for (let i = 1; i < words.length; i++) {
117 const w = words[i]!
118 if (scriptFlag.test(w) || w === '-f') {
119 i += 1
120 } else if (!w.startsWith('-') && w !== '') {
121 if (hasScript) operands.push(w)
122 hasScript = true
123 }
124 }
125
126 return operands
127}
128
129function inPlaceEdit(words: string[]): string[] | undefined {
130 const name = (words[0] ?? '').split('/').at(-1)
131 if (name === 'sed' && words.some(w => /^-[a-zA-Z]*i/.test(w) || w.startsWith('--in-place'))) {
132 return inPlaceOperands(words, /^-[a-zA-Z]*e$|^--expression/)
133 }
134 if (name === 'perl' && words.some(w => /^-[a-zA-Z0-9]*i/.test(w))) {
135 return inPlaceOperands(words, /^-[a-zA-Z0-9]*[eE]$/)
136 }
137 if ((name === 'awk' || name === 'gawk') && words.includes('inplace')) return words.slice(-1)
138 if ((name === 'ed' || name === 'ex') && words.length > 1) return words.slice(1).filter(w => !w.startsWith('-'))
139
140 return undefined
141}
142
143export function classify(command: string, context: Context): Verdict {
144 if (context.extraAllowed?.test(command)) return { kind: 'allow' }
145
146 const segments = parse(command)
147 const vars = new Map<string, string>()
148 let cwd = context.cwd
149 const found: Found[] = []
150 const isLooping = segments.some(
151 s => ['for', 'while', 'until'].includes(s.words[0] ?? '') || s.words.includes('xargs') || s.words.includes('-exec'),
152 )
153
154 // Unresolved `$vars` stay as written so a loop's `"$f"` still counts as a write.
155 const guarded = (operands: string[], keepUnresolved: boolean) =>
156 operands.flatMap(operand => {
157 const expanded = operand.includes('$') ? expand(operand, vars) : operand
158 if (expanded === undefined) return keepUnresolved ? [operand] : []
159 const path = resolve(expanded, cwd)
160 return isTempPath(path) ? [] : [path]
161 })
162
163 for (const segment of segments) {
164 const set = assignments(segment.words)
165 if (set.length > 0 && segment.redirects.length === 0) {
166 for (const [name, value] of set) vars.set(name, value)
167 continue
168 }
169
170 const words = commandWords(segment.words)
171 const name = (words[0] ?? '').split('/').at(-1) ?? ''
172
173 if (name === 'cd' && words[1]) {
174 const next = expand(words[1], vars)
175 if (next !== undefined) cwd = resolve(next, cwd)
176 continue
177 }
178
179 const execAt = words.findIndex(w => w === '-exec' || w === '-execdir')
180 const operands = inPlaceEdit(execAt >= 0 ? words.slice(execAt + 1) : words)
181 if (operands !== undefined) {
182 const targets = guarded(operands, true)
183 if (operands.length === 0 || targets.length > 0) {
184 found.push({ via: 'in-place', targets, isBulk: isLooping || operands.length === 0 })
185 }
186 continue
187 }
188
189 if (name === 'tee') {
190 const targets = guarded(words.slice(1).filter(w => !w.startsWith('-')), true)
191 if (targets.length > 0) found.push({ via: 'tee', targets, isBulk: isLooping })
192 continue
193 }
194
195 const isAllowedWriter = ALLOWED_WRITERS.test(name) || (context.extraAllowed?.test(segment.raw) ?? false)
196 const writes = segment.redirects.filter(r => r.op !== '<')
197 if (writes.length > 0 && !isAllowedWriter) {
198 const targets = guarded(writes.map(r => r.target), false)
199 if (targets.length > 0) found.push({ via: 'redirect', targets, isBulk: isLooping })
200 }
201
202 const source = inlineSource(words, segment.heredocs)
203 if (source !== undefined && WRITE_APIS.some(api => api.test(source))) {
204 const literal = interpreterTargets(source).flatMap(t => {
205 const expanded = t.includes('$') ? expand(t, vars) : t
206 return expanded === undefined || expanded.includes('${') ? [] : [resolve(expanded, cwd)]
207 })
208 const targets = literal.filter(t => !isTempPath(t))
209 const mentionsTemp = /\/(private\/)?tmp\/|\/var\/folders\/|scratchpad/.test(source)
210 const isOnlyTemp = literal.length > 0 ? targets.length === 0 : mentionsTemp && !BULK_HINTS.test(source)
211 if (!isOnlyTemp) {
212 const isBulk = targets.length === 0 && (isLooping || BULK_HINTS.test(source))
213 found.push({ via: 'interpreter', targets, isBulk })
214 }
215 }
216 }
217
218 if (found.length === 0) return { kind: 'allow' }
219
220 const targets = [...new Set(found.flatMap(f => f.targets))].map(t => display(t, context.cwd))
221 const isBulk = found.some(f => f.isBulk) || targets.length > 5 || targets.some(t => t.includes('*') || t.startsWith('$'))
222
223 return { kind: 'write', via: found[0]!.via, targets, isBulk }
224}
225
226const HOW: Record<Via, string> = {
227 interpreter: 'This script writes files from inline code',
228 'in-place': 'This command edits files in place',
229 redirect: 'This command writes a file through a shell redirect',
230 tee: 'This command writes a file through tee',
231}
232
233export function denial(verdict: Extract<Verdict, { kind: 'write' }>): string {
234 const how = HOW[verdict.via]
235 const shown = verdict.targets.filter(t => !t.startsWith('$'))
236
237 if (verdict.isBulk) {
238 const list = shown.length > 0 ? ` (${shown.slice(0, 8).join(', ')}${shown.length > 8 ? ', …' : ''})` : ''
239 return `shell-edit-guard: ${how}, and it is a bulk change${list}. Stop and ask the user: either change each file with Edit, or they run /edit-guard allow-once and you rerun this exact command.`
240 }
241
242 if (shown.length === 0) {
243 return `shell-edit-guard: ${how}. Use Edit on each file this script writes (Read it first if you haven't this session); use Write for a new file.`
244 }
245
246 const files = shown.join(', ')
247 return `shell-edit-guard: ${how}. Use Edit on ${files} instead (Read ${
248 shown.length === 1 ? 'it' : 'each'
249 } first if you haven't this session). For a new file, use Write on ${files}.`
250}
251hooks/outcome.ts 15 lines1export type Outcome = 'edit-same-file' | 'edit-other-file' | 'shell-retry' | 'bash-other' | 'other' | 'turn-ended'
2
3const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
4
5// What the model did with the tool call right after a block: the signal for whether the block message works.
6export function afterBlock(targets: readonly string[], tool: string, path: unknown, isShellWrite: boolean): Outcome {
7 if (EDIT_TOOLS.has(tool)) {
8 const file = typeof path === 'string' ? path : ''
9 return targets.some(t => file === t || file.endsWith(`/${t}`)) ? 'edit-same-file' : 'edit-other-file'
10 }
11 if (tool === 'Bash') return isShellWrite ? 'shell-retry' : 'bash-other'
12
13 return 'other'
14}
15hooks/shell.ts 258 lines1export type Redirect = { op: string; target: string }
2
3export type Segment = {
4 words: string[]
5 redirects: Redirect[]
6 heredocs: string[]
7 raw: string
8}
9
10const SEPARATORS = new Set([';', '&', '|', '\n'])
11
12// A lexer good enough to find commands, their words, redirects and heredoc bodies; it does not expand anything.
13export function parse(command: string): Segment[] {
14 const segments: Segment[] = []
15 const pendingHeredocs: { delimiter: string; stripTabs: boolean; into: string[] }[] = []
16
17 let words: string[] = []
18 let redirects: Redirect[] = []
19 let heredocs: string[] = []
20 let word = ''
21 let hasWord = false
22 let pendingRedirect: string | undefined
23 let segmentStart = 0
24 let i = 0
25
26 const endWord = () => {
27 if (!hasWord) return
28 if (pendingRedirect !== undefined) {
29 redirects.push({ op: pendingRedirect, target: word })
30 pendingRedirect = undefined
31 } else {
32 words.push(word)
33 }
34 word = ''
35 hasWord = false
36 }
37
38 const endSegment = (end: number) => {
39 endWord()
40 if (words.length > 0 || redirects.length > 0) {
41 segments.push({ words, redirects, heredocs, raw: command.slice(segmentStart, end).trim() })
42 }
43 words = []
44 redirects = []
45 heredocs = []
46 pendingRedirect = undefined
47 }
48
49 const readHeredocBodies = () => {
50 for (const { delimiter, stripTabs, into } of pendingHeredocs) {
51 const lines: string[] = []
52 while (i < command.length) {
53 const lineEnd = command.indexOf('\n', i)
54 const line = command.slice(i, lineEnd === -1 ? command.length : lineEnd)
55 i = lineEnd === -1 ? command.length : lineEnd + 1
56 if ((stripTabs ? line.replace(/^\t+/, '') : line) === delimiter) break
57 lines.push(line)
58 }
59 into.push(lines.join('\n'))
60 }
61 pendingHeredocs.length = 0
62 }
63
64 while (i < command.length) {
65 const c = command[i]!
66
67 if (c === '\\' && i + 1 < command.length) {
68 if (command[i + 1] !== '\n') word += command[i + 1]
69 hasWord = true
70 i += 2
71 continue
72 }
73
74 if (c === "'") {
75 const close = command.indexOf("'", i + 1)
76 const end = close === -1 ? command.length : close
77 word += command.slice(i + 1, end)
78 hasWord = true
79 i = end + 1
80 continue
81 }
82
83 if (c === '"') {
84 i += 1
85 while (i < command.length && command[i] !== '"') {
86 if (command[i] === '\\' && i + 1 < command.length) {
87 word += command[i + 1]
88 i += 2
89 } else {
90 word += command[i]
91 i += 1
92 }
93 }
94 hasWord = true
95 i += 1
96 continue
97 }
98
99 if (c === '$' && command[i + 1] === '(') {
100 let depth = 0
101 const start = i
102 while (i < command.length) {
103 if (command[i] === '(') depth += 1
104 if (command[i] === ')') {
105 depth -= 1
106 if (depth === 0) break
107 }
108 i += 1
109 }
110 word += command.slice(start, i + 1)
111 hasWord = true
112 i += 1
113 continue
114 }
115
116 if (c === '`') {
117 const close = command.indexOf('`', i + 1)
118 const end = close === -1 ? command.length : close
119 word += command.slice(i, end + 1)
120 hasWord = true
121 i = end + 1
122 continue
123 }
124
125 if (c === '#' && !hasWord) {
126 const lineEnd = command.indexOf('\n', i)
127 i = lineEnd === -1 ? command.length : lineEnd
128 continue
129 }
130
131 if (c === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
132 endWord()
133 i += 2
134 const stripTabs = command[i] === '-'
135 if (stripTabs) i += 1
136 while (command[i] === ' ' || command[i] === '\t') i += 1
137 const match = /^(['"]?)([A-Za-z0-9_.-]+)\1/.exec(command.slice(i))
138 if (match) {
139 pendingHeredocs.push({ delimiter: match[2]!, stripTabs, into: heredocs })
140 i += match[0].length
141 }
142 continue
143 }
144
145 if (c === '>' || (c === '&' && command[i + 1] === '>')) {
146 const fdPrefix = /^\d+$/.test(word) ? word : ''
147 if (fdPrefix) {
148 word = ''
149 hasWord = false
150 } else {
151 endWord()
152 }
153 let op = c === '&' ? '&>' : '>'
154 i += c === '&' ? 2 : 1
155 if (command[i] === '>') {
156 op += '>'
157 i += 1
158 } else if (command[i] === '|') {
159 i += 1
160 }
161 if (command[i] === '&') {
162 i += 1
163 while (/[0-9-]/.test(command[i] ?? '')) i += 1
164 continue
165 }
166 pendingRedirect = fdPrefix + op
167 continue
168 }
169
170 if (c === '<') {
171 endWord()
172 pendingRedirect = '<'
173 i += 1
174 continue
175 }
176
177 if (SEPARATORS.has(c)) {
178 endSegment(i)
179 i += 1
180 if ((c === '&' || c === '|') && command[i] === c) i += 1
181 if (c === '\n' && pendingHeredocs.length > 0) readHeredocBodies()
182 segmentStart = i
183 continue
184 }
185
186 if (c === ' ' || c === '\t' || c === '\r') {
187 endWord()
188 i += 1
189 continue
190 }
191
192 if ((c === '(' || c === ')' || c === '{' || c === '}') && !hasWord) {
193 i += 1
194 continue
195 }
196
197 word += c
198 hasWord = true
199 i += 1
200 }
201
202 endSegment(command.length)
203 if (pendingHeredocs.length > 0) readHeredocBodies()
204
205 return segments
206}
207
208const WRAPPERS = new Set(['command', 'builtin', 'exec', 'nohup', 'time', 'sudo', 'nice'])
209
210// Skips `VAR=x`, wrappers (`env`, `nohup`, `xargs -n1`, `rtk proxy`) and keywords to reach the command that runs.
211export function commandWords(words: string[]): string[] {
212 let i = 0
213 while (i < words.length) {
214 const w = words[i]!
215 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
216 i += 1
217 } else if (WRAPPERS.has(w) || ['then', 'do', 'else', '!'].includes(w)) {
218 i += 1
219 } else if (w === 'env') {
220 i += 1
221 while (i < words.length && (words[i]!.startsWith('-') || words[i]!.includes('='))) i += 1
222 } else if (w === 'xargs') {
223 i += 1
224 while (i < words.length && words[i]!.startsWith('-')) {
225 i += /^-[IJLnPsE]$/.test(words[i]!) ? 2 : 1
226 }
227 } else if (w === 'rtk' && words[i + 1] === 'proxy') {
228 i += 2
229 } else {
230 break
231 }
232 }
233
234 return words.slice(i)
235}
236
237export function assignments(words: string[]): [string, string][] {
238 const found: [string, string][] = []
239 for (const w of words[0] === 'export' ? words.slice(1) : words) {
240 const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(w)
241 if (!match) return []
242 found.push([match[1]!, match[2]!])
243 }
244
245 return found
246}
247
248export function expand(text: string, vars: ReadonlyMap<string, string>): string | undefined {
249 let isResolved = true
250 const out = text.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (_, name: string) => {
251 const value = vars.get(name)
252 if (value === undefined) isResolved = false
253 return value ?? ''
254 })
255
256 return isResolved ? out : undefined
257}
258types/index.d.ts 12 lines1export type DenyCount = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'shell-edit-guard': {
6 isOff: boolean
7 isAllowedOnce: boolean
8 deniesThisTurn: DenyCount
9 }
10 }
11}
12