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

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 zsh | Example | What the mod does |
|---|---|---|
A word starting with = is replaced by the path of a program, and the rest of the line is aborted | echo =====, [ "$a" == b ] | setopt no_equals in front |
| A value is not split into words | G="git --git-dir=…"; $G status, set -- $spec, for f in $files | setopt sh_word_split in front |
| A glob without a match aborts the command | --include=*.cs, https://…/a?b=1, rm out/* on an empty folder | setopt no_nomatch in front: the pattern passes through unchanged, as in bash |
| An alias hides the program of the same name | ls -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 alias | g() { git --git-dir=… "$@"; }; g log | \g at the definition and at every call |
timeout does not exist on macOS | timeout 300 node render.mjs | appends the mod's bin/ to PATH, where timeout stands in for GNU's |
No setup needed.
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.
timeout stand-inbin/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.
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.claude --debug logs every rewrite with the fixes applied.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.Remove the mod from CLAUDE_CODE_PLUGIN_DIRS.
hooks/register.ts 75 lines1import 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}
75hooks/shell.ts 491 lines1// 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