The model writes shell commands in bash by default. My default shell is zsh. Most of the time the difference does not matter. The single-line stuff works fine in both. Then something subtle happens (a variable holding a multi-word command, a glob that does not match anything, an array index) and the bash version fails in zsh, the model says “should work in bash though,” and the conversation pivots into diagnosing a shell incompatibility instead of solving the original problem.
This rule documents the three places zsh and bash diverge enough to matter. Once the rule is loaded, the model writes shell snippets that work in both, and I stop debugging shell instead of debugging the actual task.
The story behind the rule
The triggering case was a Bash tool call where the model tried to run a long SSH command stored in a variable:
SSH_CMD="ssh -i ~/keys/server.pem user@example.com"
$SSH_CMD "echo hello"
In bash, this works: $SSH_CMD expands and word-splits into ssh, -i, ~/keys/server.pem, user@example.com, then “echo hello” is the command to run remotely. In zsh, this fails. zsh does not word-split unquoted string variables. The expansion produces the entire string as a single command name, the shell looks up an executable named ssh -i ~/keys/server.pem user@example.com, finds nothing, errors with no such file or directory.
The model ran into this, tried again with different quoting, ran into it again, tried again. Each retry was a Bash tool call that failed. We were five turns into a session before the actual fix landed: rewrite the variable as an array. After this happened twice in the same week, I wrote the rule.
The rule
The complete rule from ~/.claude/rules/user-shell-zsh.md:
# Shell Compatibility - zsh
User's default shell is zsh. Bash tool executes commands in zsh.
## Critical: Variable word-splitting
zsh does NOT word-split unquoted string variables (unlike bash).
# BROKEN in zsh, entire string treated as one command name
CMD="ssh -i key user@host"
$CMD "echo hello" # produces "no such file or directory"
# CORRECT, use array
CMD=(ssh -i key user@host)
"${CMD[@]}" "echo hello"
## Glob no-match error
zsh errors when a glob matches nothing (bash passes the literal
string).
# BROKEN in zsh, errors if no .log files exist
rm *.log # produces "no matches found: *.log"
# CORRECT, use (N) nullglob qualifier or check first
rm *.log(N) 2>/dev/null
## Array indexing
zsh arrays are 1-based, bash arrays are 0-based.
arr=(a b c)
echo ${arr[1]} # zsh: "a", bash: "b"
## Rules
- ALWAYS use arrays (not strings) for command variables in Bash
tool calls.
- ALWAYS use `"${VAR[@]}"` to expand command arrays.
- This applies to SSH, SCP, curl, and any multi-argument command
stored in a variable.
- When using globs that might match nothing, handle the no-match
case.
- When writing shell scripts (`#!/usr/bin/env bash`), arrays are
also preferred for consistency.
- When writing skills/SKILL.md that include shell snippets, use
array syntax.
The rule is mostly examples. The model learns better from “here is the broken version, here is the fix” than from a prose explanation.
Why these three specifically
There are dozens of places bash and zsh diverge. Why call out only these three?
Because these three are the ones that produce silent or confusing failures in a Claude Code session. Other divergences (different default options, different built-in command behavior, different prompt formatting) either do not show up in the Bash tool or produce errors so obvious that the model fixes them on the first retry. The three in the rule are the ones where the model and the user can end up in a loop.
The variable word-splitting one is the most painful. The error message (“no such file or directory”) looks like a missing executable, not a quoting issue. The model’s first instinct is to check whether the executable is installed. That fails to find a real bug, the model retries with which ssh, sees the executable is there, gets more confused. The fix (use arrays) is one line but takes five turns to land without the rule.
The glob no-match one is less common but equally confusing. rm *.log works in bash even when no log files exist (bash passes the literal string *.log to rm, which prints “no such file” but exits with non-zero only on the specific file). zsh aborts the whole command with no matches found. The model’s bash mental model expects the partial-failure behavior, gets the abort behavior, and has to retry.
Array indexing is rare in practice but a hard bug to debug when it does happen. The model writes ${arr[0]} expecting the first element, gets an empty string in zsh (because arrays start at 1), assumes the array is empty, retries. The rule names the gotcha so the model writes ${arr[1]} or, better, uses "${arr[@]}" to avoid indexing at all.
Why the rule is shell-specific, not generic
A more abstract version of the rule would say “write portable POSIX shell.” That sounds right but misses the practical point. Most Claude Code shell snippets do not need to be POSIX. They run once, in my shell. The model can use zsh features if it wants. What I need is for the snippets not to break because the model assumed bash.
So the rule is specifically “zsh is the target.” Bash compatibility is fine if it falls out naturally. Bash features that break in zsh are the bug. Naming the target shell removes the ambiguity.
If you use bash, flip the rule. If you use fish, write a fish version. The pattern is the same: name the target, name the divergences from the model’s default assumption, give examples.
When the rule does not apply
The rule applies to interactive Claude Code sessions on my machine. It does not apply to:
- CI scripts that explicitly use
bashshebang. If the script starts with#!/usr/bin/env bash, bash is the target. The rule still recommends arrays for consistency, but the zsh-specific carve-outs are unnecessary. - Generated code that is meant to be portable. If I am writing a setup script for someone else’s machine, POSIX is the right target. The rule does not apply; I write to a different standard.
- Skill SKILL.md examples meant for the broader Claude Code community. Skills that I publish should work for users on bash, zsh, fish, anything. Those go through a different review where I prefer arrays anyway (so they work in zsh) but also add a comment that the snippet is array-based for cross-shell portability.
Variants you might want
- Fish shell. The same rule, with fish-specific gotchas. Fish does not even have
[[tests by default; the rule would need to name the differences from bash. - PowerShell. A very different rule, but the same shape. Name the target shell, name the things the model gets wrong about it.
- Add more zsh gotchas. I limited the rule to three because three covers 95 percent of the breakage I see. You might add others:
setoptdifferences, completion differences, history expansion differences. The rule is the right place if you hit them often. - Cross-platform shell rule. A version of the rule for someone who works across Linux and macOS could add macOS-vs-GNU coreutils gotchas (
sed -isyntax,grep -Pavailability,readlink -fmissing on macOS). Same shape: name the divergence, give an example, give the fix.
Closing note
This is the shortest payoff in the toolkit series. The rule itself is maybe 40 lines. The amount of debugging time it saves in a single bad week is hours. The model goes from “writes bash, gets zsh errors, retries, retries again, eventually adapts” to “writes arrays from the start, no retry needed.” Same task, one fifth the turns.
If you do any Claude Code work in a non-bash shell, the analogous rule for your shell is worth writing in the next ten minutes. The compounding effect of “every command works the first time” across an entire week of sessions is enormous.