Baobaobaolin.com
date
entry
024
topic
tooling
rev
—

The tool called Bash runs zsh: set -- $pair yields one argument

A loop I'd written in bash for years ran in Claude Code and set -- $pair produced one argument. The tool is called Bash; the shell it uses is zsh.

Splitting a space-separated string into $1 and $2 with set -- $pair is a bash habit I've had for years. Recently I had an agent in Claude Code run a loop written that way, and it got one argument instead of two.

The loop was written for bash's default behaviour, and the tool called Bash, in this session on my Mac, was having zsh interpret the commands. This note covers two zsh defaults that change what a one-line command does: how to check which shell you are in, what each difference looks like, and how I write commands now so neither one matters.

First check: the tool's name is not the shell

Ask the shell running the command directly:

$ ps -p $$ -o comm=
/bin/zsh
$ echo "ZSH_VERSION=$ZSH_VERSION BASH_VERSION=$BASH_VERSION"
ZSH_VERSION=5.9 BASH_VERSION=

Looking at running commands with pgrep -fl 'snapshot-zsh', the command lines I saw looked like this (abridged): /bin/zsh -c source …snapshot-zsh-….sh … eval '<your command>'. This machine's $SHELL is /bin/zsh. Claude Code's environment variable docs describe the behaviour: the Bash tool runs commands with bash or zsh, and auto-detection "uses your $SHELL when it points to bash or zsh"; to pin it to bash, set CLAUDE_CODE_SHELL to a bash binary. The tool's name is just a name.

Difference one: unquoted variables are not split

The same loop, saved to a file and run by three shells:

for pair in "zh posts" "en en/posts"; do
  set -- $pair
  echo "argc=$# 1=[$1] 2=[$2]"
done
--- zsh
argc=1 1=[zh posts] 2=[]
argc=1 1=[en en/posts] 2=[]
--- bash
argc=2 1=[zh] 2=[posts]
argc=2 1=[en] 2=[en/posts]
--- sh
argc=2 1=[zh] 2=[posts]
argc=2 1=[en] 2=[en/posts]

zsh has an option called SH_WORD_SPLIT; man zshoptions describes it as causing "field splitting to be performed on unquoted parameter expansions", and it is off by default. bash and POSIX sh split on IFS by default. So in zsh, for a non-empty string variable like this one, $pair becomes a single argument, there is no second positional parameter, and "$2" expands to an empty string.

Nothing reports an error (unless set -u is on). The shell doesn't stop because $2 is missing: if the next line is aws s3 sync . "s3://bucket/$2", the destination becomes s3://bucket/, the bucket root instead of the intended prefix. The same difference hits for f in $list: in zsh it runs once, with $f holding the whole string.

Three ways to split in zsh:

set -- ${=pair}                             # zsh-only syntax, splits this one expansion
setopt sh_word_split; set -- $pair          # enables field splitting of unquoted parameter expansions
read -r a b <<< "$pair"; set -- "$a" "$b"   # runs in both bash and zsh

All three give argc=2 1=[zh] 2=[posts] in zsh. When a command has to work in both shells, I use the third. It assumes each record is one line with two fields; any extra fields all land in b.

Difference two: a glob with no match, and that command doesn't run

I hit the second difference while researching this note. I wanted to find a string across a few repos and typed:

grep -rl ClipboardEvent --include=*.{js,mjs,py,sh,md} tools scripts docs

What came back:

(eval):1: no matches found: --include=*.js

Brace expansion produces five --include=*.xx words first, and to zsh --include=*.js is a filename pattern. No file in the current directory had a name matching --include=*.js, and zsh has NOMATCH on by default: "If a pattern for filename generation has no matches, print an error, instead of leaving it unchanged in the argument list." grep never ran. bash passes an unmatched pattern through unchanged, so the command runs normally:

$ zsh -c 'echo --include=*.nomatchext'; echo "exit=$?"
zsh:1: no matches found: --include=*.nomatchext
exit=1
$ bash -c 'echo --include=*.nomatchext'; echo "exit=$?"
--include=*.nomatchext
exit=0

Less obvious: with several patterns in one command, if any one of them matches nothing, the whole command is skipped, including the patterns that did match:

$ zsh -c 'ls -d /etc/nonexistent-* /etc/host*'; echo "exit=$?"
zsh:1: no matches found: /etc/nonexistent-*
exit=1
$ bash -c 'ls -d /etc/nonexistent-* /etc/host*'; echo "exit=$?"
ls: /etc/nonexistent-*: No such file or directory
/etc/hosts
/etc/hosts.equiv
exit=1

How much fails also depends on where the failure lands. In zsh -c on this machine, when it was in an external command (ls, grep), only that command was skipped and the following lines still ran; when it was in a builtin (such as echo) or the word list of a for loop, the whole zsh -c exited with status 1 and nothing after it ran.

It caught me while writing this note. A background watcher script checked every ten seconds whether the Codex fact-check reports (verify-*.md) had appeared. They hadn't yet, so for f in $S/verify-*.md ended the whole script with exit 1. Then I checked progress with ls $S/verify-* $S/codex-*.log, and although the codex-*.log files already existed, it listed none of them.

With the grep, I saw the error, so I knew grep never ran. The danger is when the error gets ignored, when the outer stderr is thrown away, or when only stdout is checked: grep not running and grep finding nothing both leave stdout empty, and both exit 1. The first is easily read as "that string doesn't exist". Also, adding 2>/dev/null to that command doesn't silence the error, because the expansion fails before the redirection takes effect.

The fix is to quote the patterns so they skip globbing and grep does the matching:

grep -rl \
  --include='*.js' --include='*.mjs' --include='*.py' \
  --include='*.sh' --include='*.md' \
  ClipboardEvent tools scripts docs

Why script files are fine

My deploy script starts with #!/usr/bin/env bash, so when it is executed directly, bash runs it and zsh options never apply. "Executed directly" matters: zsh script.sh or source script.sh ignores the shebang and zsh interprets it anyway. The splitting test above also gave two arguments under sh: on macOS, /bin/sh picks the real shell through /private/var/select/sh, which on this machine points to /bin/bash.

So what's affected depends on who interprets the text, not how many lines it has: commands typed into an interactive shell, commands handed to an agent tool, files sourced by zsh. The ones that have bitten me were all one-liners, because they are "just one line" and I review them least.

What I do now: anything longer than one line, or with a loop or a glob, goes into a file with #!/usr/bin/env bash and is executed directly. To have an agent's one-liners go through bash as well, set CLAUDE_CODE_SHELL. For what stays on one line, patterns are always quoted and strings are split with read.

If you remember one thing

Find out which shell is actually running the command before you trust its output. Back to the loop at the top: it raised no error, and the whole string sat in $1; it just never split into the two arguments I expected. Running ps -p $$ once is far cheaper than tracing where an empty string ended up.

Revision history

  1. First published