- date
- entry
- 024
- topic
- tooling
- rev
- —
叫 Bash 的工具跑的是 zsh:set -- $pair 只拆出一個參數
在 Claude Code 裡跑一段 bash 寫了多年的迴圈,set -- $pair 只拆出一個參數。原因是那個叫 Bash 的工具,實際用的是 zsh。
用 set -- $pair 把一個「空白分隔的字串」拆成 $1、$2,是我在 bash 裡用了很多年的寫法。最近在 Claude Code 裡讓 agent 跑一段這樣的迴圈,拆出來的參數只有一個。
這段迴圈是照 bash 的預設行為寫的,而那個叫 Bash 的工具,在我這台 Mac 的這個工作階段裡,實際解讀指令的是 zsh。這篇要談的是 zsh 與 bash 兩個會讓單行指令(one-liner)變質的預設差異。依序回答三件事:怎麼確認自己在哪個 shell、兩個差異各自長什麼樣、我現在怎麼寫才不受影響。
先確認:工具的名字不等於 shell
在工具裡直接問執行指令的 shell 是誰:
$ ps -p $$ -o comm=
/bin/zsh
$ echo "ZSH_VERSION=$ZSH_VERSION BASH_VERSION=$BASH_VERSION"
ZSH_VERSION=5.9 BASH_VERSION=
用 pgrep -fl 'snapshot-zsh' 看執行中的指令,我看到的命令列長這樣(中間省略):/bin/zsh -c source …snapshot-zsh-….sh … eval '<你的指令>'。這台機器的 $SHELL 是 /bin/zsh。Claude Code 的環境變數文件寫明了這個行為:Bash 工具用 bash 或 zsh 執行,自動偵測時「$SHELL 指向 bash 或 zsh 就用它」;要固定用 bash,可以把 CLAUDE_CODE_SHELL 設成 bash 執行檔的路徑。工具名稱只是名稱。
差異一:沒加引號的變數不會被拆開
同一段迴圈,存成檔案分別給三個 shell 跑:
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 有一個選項叫 SH_WORD_SPLIT,man zshoptions 的說明是「對沒加引號的參數展開做欄位分割」,預設是關的。bash 與 POSIX sh 預設會依 IFS 拆。所以在 zsh 裡,像這樣一個非空的字串變數,$pair 只會變成一個參數,第二個位置參數不存在,"$2" 展開成空字串。
這個錯不會報錯(除非開了 set -u)。shell 不會因為少了 $2 就停下來:後面如果是 aws s3 sync . "s3://bucket/$2",目的地會變成 s3://bucket/,同步到 bucket 根目錄,而不是原本的子路徑。同一個差異也影響 for f in $list:在 zsh 裡它只跑一圈,$f 是整串。
在 zsh 裡要拆,有三種寫法:
set -- ${=pair} # zsh 專用語法,只對這一次展開分割
setopt sh_word_split; set -- $pair # 啟用未加引號參數展開的欄位分割
read -r a b <<< "$pair"; set -- "$a" "$b" # bash 與 zsh 都能跑
三行在 zsh 裡實測都得到 argc=2 1=[zh] 2=[posts]。要在兩種 shell 裡都能跑,我選第三種;它假設每筆資料只有一行、兩個欄位,多出來的欄位會全部落進 b。
差異二:glob(萬用字元展開)沒有命中,那條指令不執行
第二個差異是在寫這篇時自己撞到的。我要在幾個 repo 裡找某個字串,下了這樣的指令:
grep -rl ClipboardEvent --include=*.{js,mjs,py,sh,md} tools scripts docs
回來的是:
(eval):1: no matches found: --include=*.js
大括號先展開成五個 --include=*.xx,而 --include=*.js 在 zsh 眼裡是一個檔名 pattern。目前目錄沒有名稱符合 --include=*.js 的檔案,而 zsh 預設開著 NOMATCH:「pattern 沒有命中時印出錯誤,而不是原封不動留在參數裡」。結果是 grep 根本沒有執行。bash 預設會把沒命中的 pattern 原樣傳給 grep,指令照常跑:
$ 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
更不直覺的是,同一條指令有好幾個 pattern 時,只要其中一個沒命中,整條都不執行,有命中的那個也一樣:
$ 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
失敗的範圍也看它卡在哪裡。我在這台機器的 zsh -c 裡測到:卡在外部指令(ls、grep)時,只有那一條不跑,後面的照跑;卡在 builtin(例如 echo)或 for 的清單時,整個 zsh -c 直接以 exit 1 結束,後面一行都沒執行。
寫這篇的過程中我就中了這一條。我讓一支背景監看腳本每十秒檢查 Codex 查核報告(verify-*.md)有沒有產生;報告還沒出現,for f in $S/verify-*.md 讓整支腳本以 exit 1 結束。接著我用 ls $S/verify-* $S/codex-*.log 看進度,codex-*.log 明明已經存在,也一個都沒列出來。
grep 那次我有看到錯誤訊息,所以知道 grep 根本沒跑。危險的是錯誤訊息被忽略、或外層的 stderr 被丟掉、或者只看 stdout 有沒有內容的時候:grep 沒跑和 grep 跑了但沒找到,stdout 都是空的,結束碼也都是 1。前者很容易被讀成「這個字串不存在」。另外,在那條指令後面加 2>/dev/null 擋不住這個錯誤,因為展開在重導向生效之前就失敗了。
修法是把 pattern 加上引號,讓它不經過 glob,交給 grep 自己比對:
grep -rl \
--include='*.js' --include='*.mjs' --include='*.py' \
--include='*.sh' --include='*.md' \
ClipboardEvent tools scripts docs
為什麼腳本檔沒事
我的部署腳本開頭是 #!/usr/bin/env bash,直接執行時交給 bash,不受 zsh 選項影響。前提是「直接執行」:用 zsh script.sh 或 source script.sh 會忽略 shebang,還是由 zsh 解讀。上面那段分割測試用 sh 跑也得到兩個參數:macOS 的 /bin/sh 依 /private/var/select/sh 決定實際的 shell,這台機器上它指向 /bin/bash。
所以受影響的範圍跟行數無關,跟「誰在解讀」有關:打進互動 shell 的指令、交給 agent 工具執行的指令、被 zsh source 的檔案。我自己踩到的都是單行指令,因為它們「只是一行」,我最少檢查。
我現在的做法:超過一行、或含有迴圈與 glob 的指令,寫成帶 #!/usr/bin/env bash 的檔案再直接執行。想讓 agent 的單行指令也走 bash,可以設定 CLAUDE_CODE_SHELL。留在單行的,pattern 一律加引號,拆字串一律用 read。
如果只記得一件事
先問指令實際是哪個 shell 在跑,再判斷輸出。回到開頭那個迴圈:它沒有報錯,整個字串都留在 $1,只是沒有拆成預期的兩個參數。跑一次 ps -p $$,比事後追一個空字串流到哪裡去便宜得多。
修訂紀錄
- 首次發布