Claude CodeでPOSIX shのbashismを直す — dashで検証
bash前提のスクリプトをAlpineやdashで動かすため、Claude Codeにbashismを洗い出させて直す手順です。[[ ]]・配列・プロセス置換の置き換え表と、dashで確かめるhookをまとめます。
#!/bin/shと書いたスクリプトが、Alpineやdashの環境でSyntax error: "(" unexpectedと止まるとき、原因はほぼ決まっています。bash専用の記法、いわゆるbashismが混ざっているのです。
直し方は三つに分かれます。bashismを検出する、POSIX shの記法に置き換える、dashで実際に動かして確かめる。Claude Codeには、この三つを一周させる規約とhookを渡しておくと任せやすくなります。
なぜbash環境では気づけないのか
手元で動くのに本番で落ちるのは、/bin/shの正体が環境ごとに違うからです。手元のmacOSでは、shはbash 3.2をPOSIXモードで動かしたものでした。
/bin/sh -c 'echo $BASH_VERSION'
# 3.2.57(1)-releaseAlpine Linuxの既定シェルはBusyBoxのashで、Debianのashの流れをくむ標準的なPOSIXシェルと説明されています。bashを前提にはできません。
そこで#!/bin/shと宣言したスクリプトは、bashの拡張なしで書いておく必要があります。宣言と中身がずれたまま持ち込むと、別のOSで初めて落ちます。
壊れ方を手元のdashで見る
次のスクリプトは、配列、[[ ]]、==、ブレース展開をまとめて使っています。bashなら動きますが、dashに渡すと2行目の配列でパースが止まります。
#!/bin/sh
files=(a.txt "b c.txt")
if [[ -n "${files[0]}" && $1 == "-v" ]]; then
echo "verbose"
fi
for i in {1..3}; do echo "$i"; done
echo "${files[1]}"dash bashy.sh -v
# bashy.sh: 2: Syntax error: "(" unexpectedエラーは1件しか出ません。構文エラーで止まるため、[[や{1..3}の問題は、配列を直すまで表に出ないのです。1件ずつ直しては実行する往復になりがちで、先に全体を洗い出す手順が要ります。
bashismの検出に使える道具と、見逃すもの
洗い出しの道具は複数あり、検出できる範囲が違います。次の表は、手元のdashとbashで試した結果と、各ツールの説明をまとめたものです。
| 道具 | 見つけるもの | 見逃すもの |
|---|---|---|
dash -n | 見つけるものパース時点の構文エラー(配列・<(...)・<<<・function f {}) | 見逃すもの[[、${x//a/b}、source、$RANDOMなど、実行時にだけ分かるもの |
bash --posix -n | 見つけるもの手元ではどれも見つけない | 見逃すもの配列も[[ ]]も通りました |
| checkbashisms | 見つけるもの「POSIXで必須でない機能」にあたる記法 | 見逃すものbashなどのshebangを持つファイルは、--forceを付けないと検査されない |
| ShellCheck | 見つけるものshebangがshやdashのとき、SC3xxx番台で移植性の警告 | 見逃すもの実行したときの挙動 |
dash -nは構文を読むだけなので、実行時に初めて失敗する記法を通してしまいます。[[ a = a ]]だけのスクリプトは、dash -nでは終了コード0でした。実行すると[[: not foundで127を返します。
bash --posixも同様です。手元のbash 3.2では、POSIXモードで[[ a = a ]]を実行してもyesと出ました。POSIXモードはbashの挙動を寄せるだけで、拡張を禁止する検査ではありません。
checkbashismsは、devscriptsに入っている、/bin/shスクリプトのbashismを調べる専用のコマンドです。manページによれば、bashismとは「POSIXで対応が必須とされていない機能」を指します。XSIなどのオプション節で許される記法も、警告されることがあります。AlpineのShell managementの記事も、bashismの除去にcheckbashismsを使うよう勧めています。
主なオプションは次のとおりです。
--posix(-p): DebianポリシーではPOSIX外でも必須とされる項目まで検査します。-nを含みます--newline(-n):echo -nの使用を検査します--lint(-l):{filename}:{lineno}:1: warning: possible bashismの形式で標準出力に出します。エディタ連携向けです--force(-f): bashなどのshebangを持つスクリプトも検査対象にします
終了コードは、bashism検出が1、読めなかったファイルがあれば2、bashスクリプトでbashismが見つからなければ4で、該当した値の合計になります。hookで使うときは、0かどうかだけを見れば足ります。
ShellCheckのSC番号の読み方はShellCheckとhookの記事にあります。移植目的のときは、shebangを#!/bin/shにしておくとSC3xxx番台が出る、という点だけ押さえておけば足ります。
bashの記法をPOSIX shにどう置き換えるか
置き換え先は、ShellCheckのwikiに一通り載っています。次の表は、dashで実際に試した反応と、置き換え例を並べたものです。
| bashの記法 | dashの反応 | 置き換え |
|---|---|---|
[[ -n "$a" && "$b" = x ]] | dashの反応[[: not found(127) | 置き換え[ -n "$a" ] && [ "$b" = x ] |
[ "$a" == x ] | dashの反応[: a: unexpected operator | 置き換え[ "$a" = x ] |
arr=(a b)、${arr[1]} | dashの反応Syntax error: "(" unexpected | 置き換えset -- a bと"$2"、"$@" |
${x//-/_}、${x:1:2}、${x^^} | dashの反応Bad substitution | 置き換えprintf '%s\n' "$x" | sed 's/-/_/g' |
done < <(cmd) | dashの反応Syntax error: redirection unexpected | 置き換え一時ファイルに出して< "$tmp" |
cat <<< "$x" | dashの反応同上 | 置き換えprintf '%s\n' "$x" | cat かヒアドキュメント |
{1..3} | dashの反応リテラルのまま展開されない | 置き換えwhile [ "$i" -le 3 ]のループ |
echo -ne 'a\tb' | dashの反応-neが出力に混ざる | 置き換えprintf 'a\tb' |
function f { ...; } | dashの反応Syntax error: "}" unexpected | 置き換えf() { ...; } |
source ./x.sh | dashの反応source: not found | 置き換え. ./x.sh |
配列の置き換えには、ShellCheckのwikiが二つの道を挙げています。位置パラメータ(set --)に入れる方法と、改行などの区切りを決めた文字列に入れる方法です。配列を一つしか使わないなら位置パラメータで足ります。複数の配列を同時に持つスクリプトは、機械的な置き換えではなく、構造から見直すことになります。
プロセス置換も同様で、wikiの代替は一時ファイルです。ストリーミングが必要なら名前付きパイプとバックグラウンド実行を組み合わせます。
$((i + 1))、${x#prefix}、${x%%.*}、command -v、getoptsは、手元のdashで問題なく動きました。迷ったら、それらの記法で書けないか先に考えます。
動かして初めて分かる、静かに壊れる記法
ここまでは、エラーが出るので気づける記法です。厄介なのは、エラーにならず結果だけがずれる記法です。
エラーを出さずに挙動が変わるもの
$RANDOMが空になる
dashでは
echo "[$RANDOM]"が[]と出ました。未定義の変数として空に展開されるだけで、警告もありません。&>がリダイレクトにならない
echo x &> /dev/nullは、echo x &と> /dev/nullに分かれて解釈されます。手元のdashでは、出力が捨てられずxが表示されました。> /dev/null 2>&1に書き直します。echo -neの-neが出力に出る
手元のdashの
echoはオプションを解釈せず、-ne aのように文字列として出力しました。一方で\tは解釈されるので、bashとは逆の挙動になります。printfなら両方で同じ結果です。
どれもdash -nでは見つかりません。構文としては正しいからです。実行して出力を比べるまで、見落とします。
pipefailとlocalをどう扱うか
POSIXの変遷にも注意が要ります。IEEE Std 1003.1-2024(Issue 8)のシェルコマンド言語の章では、パイプラインの終了ステータスがpipefailオプションの有無で決まると定義されています。set -o pipefailは、POSIXの側ではすでに仕様に入っています。
ただし、動くかどうかは使うシェルの実装次第です。手元のdashではset -o pipefailがIllegal option -o pipefailで失敗しました。set -euo pipefailをひな形にしているスクリプトは、dashで動かすと最初の行で止まる可能性があります。このひな形の効き方は、set -euo pipefailとtrapの記事にあります。
ほかにも、trap ... ERRはtrap: ERR: bad trapとなり、export -fとprintf -vもIllegal optionでした。trap ... EXIT HUP INT TERMは動くので、後始末はEXIT中心に組み直します。
localは、ShellCheckのwikiによればPOSIXで規定されていません。SC3043が警告しますが、wikiは、bash、ksh、dash、BusyBox ashが対応していることを理由に、警告を無視してよい場合があると述べています。Alpineとdashだけが対象なら、localを残すかどうかは移植先で決める判断になります。厳密にPOSIXで通すなら、関数名を前置きした変数名(_myfunc_i)で衝突を避けます。
変換後のスクリプトと実行結果
先ほどのbashy.shに、プロセス置換と文字列置換を足したうえでPOSIX shに直すと、次の形になります。
#!/bin/sh
set -eu
verbose=0
[ "${1:-}" = "-v" ] && verbose=1
set -- a.txt "b c.txt"
if [ -n "$1" ] && [ "$verbose" -eq 1 ]; then
echo "verbose"
fi
i=1
while [ "$i" -le 3 ]; do
echo "$i"
i=$((i + 1))
done
echo "$2"
name="a-b-c"
printf '%s\n' "$name" | sed 's/-/_/g'
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT HUP INT TERM
printf '1\n2\n' > "$tmp"
sum=0
while read -r n; do sum=$((sum + n)); done < "$tmp"
echo "sum=$sum"dash portable.sh -v
# verbose
# 1
# 2
# 3
# b c.txt
# a_b_c
# sum=3set -euまでにして、pipefailを外しています。配列はset --で引数に置き換えたので、元のfiles[1]は$2になりました。
配列と条件式の書き換え
bash
files=(a.txt "b c.txt")と[[ -n "${files[0]}" && $1 == "-v" ]]
POSIX sh
set -- a.txt "b c.txt"と[ -n "$1" ] && [ "$verbose" -eq 1 ]
書き換えで気をつけるのは、$1をset --で上書きしているところです。元のスクリプトの引数は、上書き前に変数へ退避しています(verbose)。引数を使う処理を移植するときは、この順序の入れ替えで挙動が変わっていないかを、Claudeに差分で説明させます。
Claude Codeに頼むときの規約
作業の進め方は、CLAUDE.mdに書いておくと毎回の依頼文が短くなります。
## シェルスクリプトの移植
- `#!/bin/sh`のスクリプトにbashの記法を入れない。配列、`[[ ]]`、`<(...)`、
`${x//a/b}`、`function`、`source`、`echo -e`、`&>`は使わない
- 直したら、必ず`dash`で実行して出力を貼る。`dash -n`だけで終わらせない
- `set -o pipefail`と`trap ... ERR`は、移植先のシェルで動くと確認できたときだけ使う
- bash依存を残す場合は、shebangを`#!/bin/bash`に変え、その理由を1行コメントに書く依頼の流れは、次の手順が収まりがよいです。
bashismの移植を一周させる手順
- 1
検出する
checkbashisms --posix script.shとshellcheck -s sh script.shの結果を、Claudeに読ませます。 - 2
置き換えを提案させる
上の置き換え表の分類(構文エラー・実行時エラー・静かに壊れるもの)ごとに、変更方針を先に出させます。
- 3
dashで実行する
テスト用の引数で
dash script.shを動かし、移植前のbash版と出力を比べさせます。 - 4
差分を確認する
git diffで、引数の扱いや変数のスコープが変わっていないかを読みます。
実行の比較には、batsでシェルスクリプトを検証する手順が使えます。テストをdashとbashの両方で回せば、移植前後の出力が一致するかを機械的に見られます。
編集直後にdashで検査するhook
規約を書いても、Claudeがbashの書き方に戻すことはあります。PostToolUseのhookで、編集直後に検査を走らせておくと確実です。
hookのスクリプト自体もPOSIX shで書きます。入力のJSONからファイルパスを取り出すのにjqを使うので、jqがPATHにある前提です。hookのドキュメントのサンプルも、JSONを読むためにjqを使っています。
#!/bin/sh
# .claude/hooks/check-posix.sh
f=$(jq -r '.tool_input.file_path // empty')
[ -n "$f" ] && [ -f "$f" ] || exit 0
case "$f" in *.sh) ;; *) exit 0 ;; esac
# #!/bin/sh のスクリプトだけを対象にする
head -n 1 "$f" | grep -q '^#!/bin/sh' || exit 0
fail=0
dash -n "$f" || fail=1
if command -v checkbashisms >/dev/null 2>&1; then
checkbashisms --posix "$f" || fail=1
fi
if [ "$fail" -ne 0 ]; then
echo "$f に bash 依存があります。POSIX sh の範囲で書き直してください" >&2
exit 2
fichmod +xで実行権を付けたうえで、.claude/settings.jsonに登録します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-posix.sh",
"args": []
}
]
}
]
}
}手元でJSONを標準入力に流して試すと、bashy.shでは次のようになりました。
printf '{"tool_input":{"file_path":"%s/bashy.sh"}}' "$PWD" | dash check-posix.sh
# bashy.sh: 2: Syntax error: "(" unexpected
# bashy.sh に bash 依存があります。POSIX sh の範囲で書き直してください
echo $?
# 2portable.shでは何も出力されず、終了コードは0でした。
終了コード2の扱いは、hookのドキュメントのとおりです。PostToolUseでは、ツールがすでに実行された後なので、書き込みは止められません。代わりにstderrがClaudeに渡るので、次の編集で直すきっかけになります。それ以外のコードで終わると、非ブロッキングエラーとして扱われ、Claudeには伝わりません。
落とし穴
dash -nが通っても、実行時エラーは残る
このhookの中心はdash -nの構文検査なので、[[や${x//a/b}のような記法は素通りします。checkbashismsが入っていれば追加で検査しますが、入っていない環境では、dashでの実行確認をCIやbatsといった別の手順で補います。
checkbashismsはdevscriptsパッケージに入っています。Alpineのコンテナで確かめたいときは、Dockerが使える環境でdocker run --rm -v "$PWD":/w alpine sh /w/portable.shのようにBusyBox ashで走らせる手もあります。BusyBox ashはdashと完全に同じではないため、dashで通ったあとの最終確認に使います。
まとめ
bashismの移植は、検出、置き換え、実行確認の三つに分けると進みます。dash -nは構文だけ、bash --posixは禁止の検査ではない、という前提を知っておくと、通ったのに本番で落ちる事態を避けられます。
Claude Codeには、CLAUDE.mdで「dashで実行して出力を貼る」ことを求めておきます。編集直後のhookは構文エラーとcheckbashismsの指摘を返すだけなので、静かに壊れる記法は実行結果の比較で拾う構成になります。pipefailとlocalだけは、POSIXの仕様と移植先の実装が食い違うので、使う前に対象のシェルで動かして決めます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。