SLOPSHOPPER

shell-shim

Fixes the zsh pitfalls Claude's bash-style commands trip over — = words, unmatched globs, word splitting, aliases that hide programs, a missing timeout —…

newguardprocess
★ 2v0.1.0MITupdated 2026-10-10MichaelP17/claude-mods/shell-shim
A shopper browsing a rack in a slop shop
README

shell-shim

Fixes the zsh pitfalls Claude's commands trip over, before they run. Claude writes shell commands the way bash reads them; when your shell is zsh, some of them fail or stop halfway, and each failure costs a retry on the whole context. The mod changes only the commands that would hit one of these pitfalls and leaves every other command exactly as Claude wrote it.

Pitfall in zshExampleWhat the mod does
A word starting with = is replaced by the path of a program, and the rest of the line is abortedecho =====, [ "$a" == b ]setopt no_equals in front
A value is not split into wordsG="git --git-dir=…"; $G status, set -- $spec, for f in $filessetopt sh_word_split in front
A glob without a match aborts the command--include=*.cs, https://…/a?b=1, rm out/* on an empty foldersetopt no_nomatch in front: the pattern passes through unchanged, as in bash
An alias hides the program of the same namels -t runs eza, which reads -t differently\ls: the backslash skips the alias
A function cannot be defined under an alias's name, and calling it runs the aliasg() { git --git-dir=… "$@"; }; g log\g at the definition and at every call
timeout does not exist on macOStimeout 300 node render.mjsappends the mod's bin/ to PATH, where timeout stands in for GNU's

No setup needed.

How it decides

At the first shell command of a session the mod sources the newest zsh snapshot the Bash tool uses (~/.claude/shell-snapshots/) in a zsh of its own and asks it which aliases exist, which of them hide a program on PATH, and whether timeout is installed. It asks again when the snapshot changes.

Each command is then read the way the shell reads it: text in quotes, heredoc bodies and comments are data and never touched; commands inside $(…), backticks and <(…) are read like any other. The options go in front of the command only when it needs them, and only the ones it needs.

Word splitting is turned on only for the bash idioms that depend on it: a value used as the command, set with a value, the word list of for, and an array built from a value. A value used as a plain argument keeps zsh's behaviour, so ls $DIR keeps working when the path holds a space, such as ~/Knowledge/Second Brain.

Only aliases that hide a real program are skipped, ls, egrep and fgrep on a typical Oh My Zsh setup. Claude Code itself replaces grep and find with its own functions in the snapshot; the mod leaves those alone. A name after timeout, sudo, env or xargs is not an alias there in the first place.

The timeout stand-in

bin/timeout is a short Perl script, since Perl ships with macOS. It takes DURATION COMMAND [ARG]... with s, m, h and d suffixes and fractions, -s SIGNAL and -k DURATION, and exits like GNU timeout: 124 when the time ran out, 125 for its own errors, 126 or 127 when the command cannot run, otherwise the command's own status. It is appended to PATH, so a real timeout installed later always wins.

Details and limits

  • A command that starts with sleep is never changed. That is how Claude polls, and Claude Code refuses it on purpose; an option in front would hide the sleep from that check.
  • The rewritten command is what the permission check sees. In auto mode that changes nothing; in manual mode a rewritten command may ask where the original would have run without asking.
  • Claude only sees the result, not the rewrite. claude --debug logs every rewrite with the fixes applied.
  • The reading is a heuristic, not a full shell parser. A construct it misreads, such as a case inside $(…), is at worst left unfixed: the only changes it ever makes are options in front, a PATH addition and a backslash before an unquoted word, which changes nothing where no alias is involved.
  • Under bash there is nothing to fix: without a zsh snapshot the mod does nothing.

Uninstall

Remove the mod from CLAUDE_CODE_PLUGIN_DIRS.

Source 2 files
hooks/register.ts 75 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { ShellFacts } from './shell'
4import { parseFacts, rewrite } from './shell'
5
6// Sources the snapshot the Bash tool sources and asks zsh itself: which aliases
7// exist, which of them hide a program on PATH, and whether `timeout` is there.
8const PROBE = [
9  'source "$1" >/dev/null 2>&1',
10  'print -r -- "timeout:$(whence -p timeout)"',
11  'for name in ${(k)aliases}; do print -r -- "alias:$name"; whence -p -- "$name" >/dev/null 2>&1 && print -r -- "shadow:$name"; done',
12].join('; ')
13
14let cached: { key: string; facts: Promise<ShellFacts | null> } | undefined
15
16async function newestSnapshot($: EngineInterface): Promise<{ path: string; key: string } | null> {
17  const home = (await $.env.get('HOME')) ?? ''
18  const directory = `${(await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`}/shell-snapshots`
19  if (!(await $.fs.exists(directory))) {
20    return null
21  }
22  const entries = await $.fs.list(directory)
23  const newest = entries
24    .filter(i => i.kind === 'file' && i.name.startsWith('snapshot-zsh-'))
25    .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
26
27  return newest === undefined ? null : { path: `${directory}/${newest.name}`, key: `${newest.name}:${newest.mtimeMs}` }
28}
29
30async function probe($: EngineInterface, snapshot: string): Promise<ShellFacts | null> {
31  try {
32    const result = await $.process.run(['/bin/zsh', '-fc', PROBE, 'shell-shim', snapshot], { timeoutMs: 10_000 })
33
34    return result.stdout.includes('timeout:') ? parseFacts(result.stdout) : null
35  } catch {
36    return null
37  }
38}
39
40// Only a zsh snapshot gets facts: under bash the commands already run the way
41// Claude writes them.
42async function factsOf($: EngineInterface): Promise<ShellFacts | null> {
43  const snapshot = await newestSnapshot($)
44  if (snapshot === null) {
45    return null
46  }
47  if (cached?.key !== snapshot.key) {
48    cached = { key: snapshot.key, facts: probe($, snapshot.path) }
49  }
50
51  return cached.facts
52}
53
54export const register: Register = on => {
55  on('session.start', async ($, e, next) => {
56    void factsOf($)
57
58    return next(e)
59  })
60
61  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
62    if (typeof e.command !== 'string') {
63      return next(e)
64    }
65    const facts = await factsOf($)
66    const changed = facts === null ? null : rewrite(e.command, facts, `${$.plugin.root}/bin`)
67    if (changed === null) {
68      return next(e)
69    }
70    $.ui.log(`shell-shim: ${changed.fixes.join(', ')} · ${changed.command.slice(0, 300)}`, { to: 'debug' })
71
72    return next({ ...e, command: changed.command })
73  })
74}
75
hooks/shell.ts 491 lines
1// What the shell the Bash tool runs in knows: every alias, the aliases that
2// hide a real program of the same name (ls → eza), and whether `timeout` exists.
3export type ShellFacts = {
4  aliases: ReadonlySet<string>
5  shadowing: ReadonlySet<string>
6  hasTimeout: boolean
7}
8
9export type Findings = {
10  needsNoEquals: boolean
11  needsNoNomatch: boolean
12  needsWordSplit: boolean
13  commandWords: CommandWord[]
14  definitions: string[]
15}
16
17export type CommandWord = {
18  start: number
19  text: string
20}
21
22export type Rewrite = {
23  command: string
24  fixes: string[]
25}
26
27type Word = {
28  start: number
29  end: number
30  literal: string | null
31  startsWithEquals: boolean
32  hasGlob: boolean
33  hasParameter: boolean
34  isAssignment: boolean
35}
36
37// Words after these are in command position again, where zsh expands aliases.
38const KEYWORDS = new Set(['!', 'if', 'then', 'else', 'elif', 'do', 'while', 'until', '{', 'time'])
39
40const ALIAS_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/
41
42class Scanner {
43  private index = 0
44  private readonly heredocs: { delimiter: string; stripsTabs: boolean }[] = []
45  readonly findings: Findings = {
46    needsNoEquals: false,
47    needsNoNomatch: false,
48    needsWordSplit: false,
49    commandWords: [],
50    definitions: [],
51  }
52
53  constructor(private readonly text: string) {}
54
55  private at(offset = 0): string {
56    return this.text[this.index + offset] ?? ''
57  }
58
59  // One list of commands, up to `terminator` (the `)` of `$(`, a backtick) or
60  // the end. Mis-reading an odd construct is harmless: rewrites only touch
61  // unquoted words, where a backslash before a letter changes nothing.
62  scanList(terminator: ')' | '`' | null): void {
63    let isCommandStart = true
64    let isInTest = false
65    let isLoopList = false
66    let isLoopHeader = false
67    let isFunctionName = false
68    let command: string | null = null
69    while (this.index < this.text.length) {
70      const char = this.at()
71      if (terminator !== null && char === terminator) {
72        this.index += 1
73        return
74      }
75      if (char === '\n') {
76        this.index += 1
77        this.skipHeredocBodies()
78        isCommandStart = true
79        isLoopList = false
80        isLoopHeader = false
81        command = null
82        continue
83      }
84      if (char === ' ' || char === '\t') {
85        this.index += 1
86        continue
87      }
88      if (char === '#' ) {
89        while (this.index < this.text.length && this.at() !== '\n') {
90          this.index += 1
91        }
92        continue
93      }
94      if (char === ';' || char === '|' || (char === '&' && this.at(1) !== '>')) {
95        this.index += char === this.at(1) ? 2 : 1
96        if (char === '|' && this.at() === '&') {
97          this.index += 1
98        }
99        isCommandStart = true
100        isInTest = false
101        isLoopList = false
102        isLoopHeader = false
103        command = null
104        continue
105      }
106      if (char === '(' && this.at(1) === '(') {
107        this.skipArithmetic(2)
108        isCommandStart = false
109        continue
110      }
111      if (char === '(') {
112        this.index += 1
113        this.scanList(')')
114        isCommandStart = false
115        continue
116      }
117      if (char === ')') {
118        this.index += 1
119        continue
120      }
121      if (char === '<' || char === '>' || char === '&') {
122        this.scanRedirection()
123        continue
124      }
125      const word = this.scanWord()
126      if (word.end === word.start) {
127        this.index += 1
128        continue
129      }
130      if (/^\d+$/.test(word.literal ?? '') && (this.at() === '<' || this.at() === '>')) {
131        continue
132      }
133      if (isFunctionName) {
134        isFunctionName = false
135        if (word.literal !== null) {
136          this.findings.definitions.push(word.literal)
137        }
138        if (this.isDefinitionAhead()) {
139          this.index = this.text.indexOf(')', this.index) + 1
140        }
141        isCommandStart = true
142        continue
143      }
144      if (isCommandStart) {
145        if (word.isAssignment) {
146          if (this.at() === '(') {
147            this.scanArrayLiteral()
148          }
149          continue
150        }
151        isCommandStart = false
152        command = word.literal
153        if (word.literal === null) {
154          // `$cmd args`: bash splits the value into a command and its words.
155          this.findings.needsWordSplit ||= word.hasParameter
156          continue
157        }
158        this.findings.commandWords.push({ start: word.start, text: word.literal })
159        if (this.isDefinitionAhead()) {
160          this.findings.definitions.push(word.literal)
161          this.index = this.text.indexOf(')', this.index) + 1
162          isCommandStart = true
163          continue
164        }
165        if (KEYWORDS.has(word.literal)) {
166          isCommandStart = true
167        } else if (word.literal === 'function') {
168          isFunctionName = true
169        } else if (word.literal === '[[') {
170          isInTest = true
171        } else if (word.literal === 'for' || word.literal === 'select' || word.literal === 'foreach') {
172          isLoopHeader = true
173        }
174        continue
175      }
176      if (isInTest) {
177        isInTest = word.literal !== ']]'
178        continue
179      }
180      if (isLoopHeader && word.literal === 'in') {
181        isLoopHeader = false
182        isLoopList = true
183        continue
184      }
185      if (isLoopList || command === 'set') {
186        this.findings.needsWordSplit ||= word.hasParameter
187      }
188      if (word.literal === 'do' && (isLoopList || isLoopHeader)) {
189        isLoopList = false
190        isLoopHeader = false
191        isCommandStart = true
192        continue
193      }
194      // A lone `=` is a test operator; `==` and `=word` are what zsh expands.
195      this.findings.needsNoEquals ||= word.startsWithEquals
196      this.findings.needsNoNomatch ||= word.hasGlob
197    }
198  }
199
200  private isDefinitionAhead(): boolean {
201    let offset = 0
202    while (this.at(offset) === ' ' || this.at(offset) === '\t') {
203      offset += 1
204    }
205
206    return this.at(offset) === '(' && this.at(offset + 1) === ')'
207  }
208
209  private scanArrayLiteral(): void {
210    this.index += 1
211    while (this.index < this.text.length && this.at() !== ')') {
212      if (/\s/.test(this.at())) {
213        this.index += 1
214        continue
215      }
216      const word = this.scanWord()
217      this.findings.needsWordSplit ||= word.hasParameter
218      if (word.end === word.start) {
219        this.index += 1
220      }
221    }
222    this.index += 1
223  }
224
225  private scanRedirection(): void {
226    const operator = /^(<<<|<<-?|<>|<&|>&|>>|>\||&>>|&>|<|>)/.exec(this.text.slice(this.index))?.[0] ?? this.at()
227    this.index += operator.length
228    if ((operator === '<' || operator === '>') && this.at() === '(') {
229      this.index += 1
230      this.scanList(')')
231      return
232    }
233    while (this.at() === ' ' || this.at() === '\t') {
234      this.index += 1
235    }
236    const target = this.scanWord()
237    if (operator === '<<' || operator === '<<-') {
238      const delimiter = this.text.slice(target.start, target.end).replace(/['"\\]/g, '')
239      this.heredocs.push({ delimiter, stripsTabs: operator === '<<-' })
240    }
241  }
242
243  private skipHeredocBodies(): void {
244    while (this.heredocs.length > 0) {
245      const heredoc = this.heredocs.shift()
246      if (heredoc === undefined) {
247        return
248      }
249      while (this.index < this.text.length) {
250        const lineEnd = this.text.indexOf('\n', this.index)
251        const end = lineEnd === -1 ? this.text.length : lineEnd
252        const line = this.text.slice(this.index, end)
253        this.index = lineEnd === -1 ? end : end + 1
254        if ((heredoc.stripsTabs ? line.replace(/^\t+/, '') : line) === heredoc.delimiter) {
255          break
256        }
257      }
258    }
259  }
260
261  private skipArithmetic(opening: number): void {
262    let depth = 0
263    this.index += opening
264    while (this.index < this.text.length) {
265      const char = this.at()
266      if (char === '(') {
267        depth += 1
268      } else if (char === ')') {
269        if (depth === 0) {
270          this.index += this.at(1) === ')' ? 2 : 1
271          return
272        }
273        depth -= 1
274      }
275      this.index += 1
276    }
277  }
278
279  private skipBraced(): void {
280    let depth = 0
281    while (this.index < this.text.length) {
282      const char = this.at()
283      if (char === '\\') {
284        this.index += 2
285        continue
286      }
287      if (char === '\'' ) {
288        this.skipSingleQuoted()
289        continue
290      }
291      if (char === '{') {
292        depth += 1
293      } else if (char === '}') {
294        depth -= 1
295        if (depth === 0) {
296          this.index += 1
297          return
298        }
299      }
300      this.index += 1
301    }
302  }
303
304  private skipSingleQuoted(): void {
305    const end = this.text.indexOf('\'', this.index + 1)
306    this.index = end === -1 ? this.text.length : end + 1
307  }
308
309  private skipAnsiQuoted(): void {
310    this.index += 2
311    while (this.index < this.text.length && this.at() !== '\'') {
312      this.index += this.at() === '\\' ? 2 : 1
313    }
314    this.index += 1
315  }
316
317  // Inside double quotes nothing splits or globs; only `$(` and backticks hold
318  // commands of their own.
319  private skipDoubleQuoted(): void {
320    this.index += 1
321    while (this.index < this.text.length) {
322      const char = this.at()
323      if (char === '"') {
324        this.index += 1
325        return
326      }
327      if (char === '\\') {
328        this.index += 2
329      } else if (char === '$' && this.at(1) === '(' && this.at(2) === '(') {
330        this.index += 1
331        this.skipArithmetic(2)
332      } else if (char === '$' && this.at(1) === '(') {
333        this.index += 2
334        this.scanList(')')
335      } else if (char === '$' && this.at(1) === '{') {
336        this.index += 1
337        this.skipBraced()
338      } else if (char === '`') {
339        this.index += 1
340        this.scanList('`')
341      } else {
342        this.index += 1
343      }
344    }
345  }
346
347  private scanWord(): Word {
348    const start = this.index
349    let isLiteral = true
350    let hasGlob = false
351    let hasParameter = false
352    const startsWithEquals = this.at() === '=' && this.at(1) !== '' && !/[\s;&|()<>]/.test(this.at(1))
353    while (this.index < this.text.length) {
354      const char = this.at()
355      if (/[\s;&|()<>]/.test(char)) {
356        break
357      }
358      if (char === '\\') {
359        isLiteral = false
360        this.index += 2
361      } else if (char === '\'') {
362        isLiteral = false
363        this.skipSingleQuoted()
364      } else if (char === '"') {
365        isLiteral = false
366        this.skipDoubleQuoted()
367      } else if (char === '$' && this.at(1) === '\'') {
368        isLiteral = false
369        this.skipAnsiQuoted()
370      } else if (char === '$' && this.at(1) === '(' && this.at(2) === '(') {
371        isLiteral = false
372        this.index += 1
373        this.skipArithmetic(2)
374      } else if (char === '$' && this.at(1) === '(') {
375        isLiteral = false
376        this.index += 2
377        this.scanList(')')
378      } else if (char === '$' && this.at(1) === '{') {
379        isLiteral = false
380        hasParameter = true
381        this.index += 1
382        this.skipBraced()
383      } else if (char === '$' && /[A-Za-z_]/.test(this.at(1))) {
384        isLiteral = false
385        hasParameter = true
386        this.index += 1
387        while (/[A-Za-z0-9_]/.test(this.at())) {
388          this.index += 1
389        }
390      } else if (char === '$' && /[0-9@*#?!$-]/.test(this.at(1))) {
391        isLiteral = false
392        hasParameter = true
393        this.index += 2
394      } else if (char === '`') {
395        isLiteral = false
396        this.index += 1
397        this.scanList('`')
398      } else {
399        hasGlob ||= char === '*' || char === '?' || char === '['
400        this.index += 1
401      }
402    }
403    const raw = this.text.slice(start, this.index)
404
405    return {
406      start,
407      end: this.index,
408      literal: isLiteral ? raw : null,
409      startsWithEquals,
410      hasGlob,
411      hasParameter,
412      isAssignment: /^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=/.test(raw),
413    }
414  }
415}
416
417export function scan(command: string): Findings {
418  const scanner = new Scanner(command)
419  scanner.scanList(null)
420
421  return scanner.findings
422}
423
424function firstCommandOf(findings: Findings): string | undefined {
425  return findings.commandWords[0]?.text
426}
427
428// `timeout` comes from the mod's own bin folder, appended to PATH so a real one
429// installed later always wins.
430export function rewrite(command: string, facts: ShellFacts, binDirectory: string): Rewrite | null {
431  const findings = scan(command)
432  // A leading sleep is how Claude polls; Claude Code refuses that on purpose, and
433  // a prefix in front of it would hide the sleep from that check.
434  if (firstCommandOf(findings) === 'sleep') {
435    return null
436  }
437  const redefined = new Set(findings.definitions.filter(i => facts.aliases.has(i)))
438  const bypassed = findings.commandWords.filter(
439    i => ALIAS_NAME.test(i.text) && (facts.shadowing.has(i.text) || redefined.has(i.text)),
440  )
441  const isTimeoutMissing =
442    !facts.hasTimeout && findings.commandWords.some(i => i.text === 'timeout') && !findings.definitions.includes('timeout')
443  const options = [
444    findings.needsNoEquals ? 'no_equals' : null,
445    findings.needsNoNomatch ? 'no_nomatch' : null,
446    findings.needsWordSplit ? 'sh_word_split' : null,
447  ].filter(i => i !== null)
448  if (bypassed.length === 0 && options.length === 0 && !isTimeoutMissing) {
449    return null
450  }
451  let body = command
452  for (const word of [...bypassed].sort((a, b) => b.start - a.start)) {
453    body = `${body.slice(0, word.start)}\\${body.slice(word.start)}`
454  }
455  const prefixes = [
456    options.length > 0 ? `setopt ${options.join(' ')} 2>/dev/null` : null,
457    isTimeoutMissing ? `PATH="$PATH":${shellQuote(binDirectory)}` : null,
458  ].filter(i => i !== null)
459  const fixes = [
460    ...options,
461    ...[...new Set(bypassed.map(i => i.text))].map(i => `\\${i}`),
462    ...(isTimeoutMissing ? ['timeout'] : []),
463  ]
464
465  return { command: prefixes.length > 0 ? `${prefixes.join('; ')}; ${body}` : body, fixes }
466}
467
468function shellQuote(text: string): string {
469  return `'${text.replace(/'/g, `'\\''`)}'`
470}
471
472// The probe prints one line per fact; see register.ts for the script.
473export function parseFacts(output: string): ShellFacts {
474  const aliases = new Set<string>()
475  const shadowing = new Set<string>()
476  let hasTimeout = false
477  for (const line of output.split('\n')) {
478    const [kind = '', ...rest] = line.split(':')
479    const value = rest.join(':')
480    if (kind === 'alias') {
481      aliases.add(value)
482    } else if (kind === 'shadow') {
483      shadowing.add(value)
484    } else if (kind === 'timeout') {
485      hasTimeout = value.length > 0
486    }
487  }
488
489  return { aliases, shadowing, hasTimeout }
490}
491