SLOPSHOPPER

shell-edit-guard

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

newguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shell-edit-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /edit-guard ⎿ shell-edit-guard: Shell-edit guard is on. Usage: /edit-guard off | on | allow-once ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Function-hook plugins for Claude Code. Each one installs on its own.

Install

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.

shell-edit-guard

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

dev-servers

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

handoff

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

aq

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

Developing

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.

License

MIT

Source 5 files
hooks/register.ts 133 lines
1import { 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}
133
hooks/classify.ts 251 lines
1import { 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}
251
hooks/outcome.ts 15 lines
1export 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}
15
hooks/shell.ts 258 lines
1export 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}
258
types/index.d.ts 12 lines
1export 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