Claude CodeでShellCheckを使う — SC番号の読み方と編集直後のhook
Claude Codeが書いたシェルスクリプトをShellCheckで検査する方法です。SC2086などの警告コードの読み方と、編集直後に走らせるPostToolUse hookの設定をまとめます。
ShellCheckは、bashやshのスクリプトを読んで警告を出す静的解析ツールです。警告にはSC2086のような番号が付き、番号ごとにWikiページがあります。Claude Codeにシェルスクリプトを書かせるときは、この番号を判断の共通語にすると、直す箇所と見送る箇所をそろえやすくなります。
方法は2つあります。Claudeにその場でshellcheckを実行させて読ませる方法と、ファイルを編集した直後にhookで自動実行する方法です。この記事は、よく出る警告コードの意味、hookの設定、警告を黙らせるときの作法を順に扱います。
ShellCheckが指摘する落とし穴の種類
ShellCheckの目的は、シェルが出す分かりにくいエラーの原因となる初歩的な構文の問題、挙動を不可解にする中級レベルの意味上の問題、将来壊れかねない細かな落とし穴を指摘することです。リポジトリの「Gallery of bad code」は、検出対象をクォート、条件式、コマンドの誤用、初心者の間違い、スタイル、データと型、堅牢性、移植性、その他の9つに分けています。
クォートの欄だけでも、echo $1(変数のクォート漏れ)、find . -name *.ogg(パターンのクォート漏れ)、echo 'Path is $PATH'(シングルクォート内の変数)などが並びます。堅牢性の欄にはrm -rf "$STEAMROOT/"*(壊滅的なrm)やfor f in $(ls *.txt)(lsの出力を回す書き方)があります。
つまり、シェルスクリプトで事故につながりやすい書き癖の多くが、番号付きの警告として機械的に拾えます。コードレビューで「ここはクォートしてください」と言葉で伝える代わりに、番号と出力をそのままClaudeに返せます。
よく出る警告コード8種の読み方
Claudeにスクリプトを書かせたとき、レビューで目にする機会が多い番号を、ShellCheckのWikiの記述に沿って並べます。
| コード | 警告の趣旨 | 直し方 |
|---|---|---|
| SC2086 | 警告の趣旨globbingと単語分割を防ぐため二重引用符で囲む | 直し方echo "$1"、for i in "$@" |
| SC2046 | 警告の趣旨コマンド置換の結果にも単語分割が起きる | 直し方ls -l "$(getfilename)" |
| SC2154 | 警告の趣旨変数が参照されているが代入されていない | 直し方綴りを確認、または$(...)の書き間違いを直す |
| SC2155 | 警告の趣旨宣言と代入を分けないと終了コードが隠れる | 直し方foo="$(mycmd)"のあとにexport foo |
| SC2164 | 警告の趣旨cdが失敗したときに続行してしまう | 直し方cd dir || exit |
| SC2162 | 警告の趣旨readが-rなしだとバックスラッシュを解釈する | 直し方read -r name |
| SC2034 | 警告の趣旨変数が使われていないように見える | 直し方使うか、exportするか、意図的なら無視 |
| SC2016 | 警告の趣旨シングルクォート内では式が展開されない | 直し方展開したいなら二重引用符にする |
SC2086とSC2046 — クォート漏れの2大パターン
echo $1は「最初の引数を表示する」ように見えて、実際はIFS(空白、タブ、改行)で分割し、それぞれをglobとして展開し、結果を空白でつないで表示します。Wikiの説明です。ファイル名に空白が入っただけで壊れる書き方だと考えると、SC2086の重みが分かります。
SC2046は同じ話が$(...)に移ったものです。ls -l $(getfilename)の結果は分割されるので、ls -l "$(getfilename)"のようにコマンド置換自体を囲みます。複数のファイルを返すコマンドなら、while IFS='' read -r lineのループに切り替えるのがWikiの案です。
意図して単語分割したい場面もあります。Wikiはgcc $(pkg-config --libs openssl) client.cを例に挙げ、このときだけ# shellcheck disable=SC2046で無視する形を示しています。
SC2154 — 未設定変数は小文字だけが対象
「未設定変数」を潰したいときに押さえておく点があります。SC2154が反応するのは、名前に小文字を含む変数だけです。fooやkFOOは対象で、FOOは対象外です。環境変数として外から渡される大文字の変数を誤検出しないための仕様です。
大文字の変数も検査したいなら、オプションのチェックcheck-unassigned-uppercaseを.shellcheckrcで有効にします。
enable=check-unassigned-uppercaseClaudeが$DEPLOY_DIRのような大文字変数を、定義しないまま参照する書き間違いは、既定設定のShellCheckでは通ります。この設定を足すかどうかは、環境変数の渡し方が決まっているリポジトリかで判断が分かれます。
SC2155 — export時のコマンド置換が終了コードを隠す
export foo="$(mycmd)"と書くと、mycmdの終了コードは捨てられ、exportが常に成功を返します。set -eやtrap、条件分岐が期待どおり働かなくなるのが理由です。Wikiの直し方は、代入とexportを2行に分ける書き方です。
foo="$(mycmd)"
export foolocal foo="$(cmd)"にも同じ指摘が出ます。エラー処理を入れたスクリプトでこそ効く警告です。
SC2164・SC2162・SC2034・SC2016
SC2164は、cdの失敗後も処理が続き、間違ったディレクトリでrmなどが走る事態を防ぎます。Wikiはcd generated_files || exitを基本形とし、関数内では|| returnを使う案も示しています。
SC2162は、readに-rが無いとバックスラッシュがエスケープとして解釈される点を指します。先頭と末尾の空白も削られるので、IFS= read -r nameが最も安全な書き方です。
SC2034の注意点は、間接参照です。name=fooとして${!name}で読むような使い方だと、fooは未使用と判定されます。Wikiは、これを設計上の挙動として説明しています。
SC2016は、echo 'Hello $name'のようにシングルクォートの中へ変数を書いたときの警告です。envsubst '${SERVICE_HOST}'のように、展開を止めたい意図的な書き方では無視が正解になります。
出力形式と終了コードを知っておく
hookに載せる前に、コマンドラインの挙動を確認します。インストールはHomebrewならbrew install shellcheck、Debian系ならsudo apt install shellcheckです。
shellcheck --version
shellcheck --format=gcc --severity=warning deploy.sh--format=gccは、<file>:<line>:<column>: <type>: <message>の1行形式で出力します。Claudeに返す文字数が抑えられ、ファイル名と行番号が揃うので、編集箇所を探させやすい形式です。例えば次のような形になります(形式は公式のmanページに沿った説明用の例です)。
deploy.sh:12:8: warning: Quote this to prevent word splitting. [SC2046]終了コードは5種類です。
| 終了コード | 意味 |
|---|---|
| 0 | 意味全ファイルを検査し、指摘なし |
| 1 | 意味全ファイルを検査し、指摘あり |
| 2 | 意味一部のファイルを処理できなかった(ファイルが無いなど) |
| 3 | 意味構文の誤った呼び出し(未知のフラグなど) |
| 4 | 意味不正なオプション指定(未知のフォーマッタなど) |
指摘ありは終了コード1なので、hookでは0かどうかで分岐すれば足ります。重症度は-S(--severity)でerror、warning、info、styleから選べ、既定はstyleです。pre-commit用の設定例でも、警告以上だけに絞る--severity=warningが任意の引数として載っています。
編集直後に走らせるPostToolUse hook
Claude CodeのPostToolUse hookは、ツールの呼び出しが成功した直後に動きます。matcherにEdit|Writeを指定すれば、ファイルを書き換えたタイミングで起動できます。入力のJSONにはtool_input.file_pathが絶対パスで入ります。
.claude/settings.jsonには次のように書きます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/shellcheck.sh",
"timeout": 30
}
]
}
]
}
}スクリプトは.shと.bashだけを対象にして、指摘があれば出力をstderrに流して終了コード2で終えます。
mkdir -p .claude/hooks
cat > .claude/hooks/shellcheck.sh <<'SH'
#!/bin/bash
file_path=$(jq -r '.tool_input.file_path // empty')
[ -z "$file_path" ] && exit 0
case "$file_path" in
*.sh|*.bash) ;;
*) exit 0 ;;
esac
[ -f "$file_path" ] || exit 0
command -v shellcheck >/dev/null || exit 0
output=$(shellcheck --format=gcc --severity=warning "$file_path" 2>&1)
status=$?
if [ "$status" -ne 0 ]; then
echo "$output" >&2
exit 2
fi
exit 0
SH
chmod +x .claude/hooks/shellcheck.sh終了コード2を使う理由は、公式のhooksリファレンスにあります。終了コード0で終えたhookのstderrはClaudeに届きません。PostToolUseでは、すでに実行されたツールを取り消せない代わりに、終了コード2のstderrがClaudeに渡されます。ファイルの書き込み自体は済んでいるので、返すのは「次に直してほしい指摘」です。
このスクリプト自身も、ShellCheckにかけて通る書き方にしてあります。shellcheckが未導入の環境では黙って通過する点は割り切りです。チームで使うなら、導入手順をREADMEかsetupスクリプトに入れておくと、通過したのか検査されたのかが曖昧になりません。
hookの基本構文と他のイベントとの違いはPostToolUse hookでツール実行後の後処理を自動化するにあります。同じ型でBiomeを載せた例はClaude CodeでBiomeを使う、言語別のコマンドを一覧にしたものはPostToolUse hookのLint自動修正です。
警告を黙らせるときの作法
ShellCheckの指摘は、すべてが直すべきものとは限りません。意図的な書き方には、ディレクティブで無視を明示できます。
# glob展開を避けるため、シングルクォートのまま渡す
# shellcheck disable=SC2016
envsubst '${SERVICE_HOST}:${SERVICE_PORT}' config.template > configディレクティブは対象コマンドの直前の行に書きます。公式のWikiは、無視する理由をコメントで残すことを勧めています。短い理由なら# shellcheck disable=SC1234 # this is intentionalのように同じ行の末尾でも書けます。
プロジェクト全体で無効にしたい番号は、.shellcheckrcにdisable=SC2236の形で書きます。.shellcheckrcはスクリプトのあるディレクトリと、その親ディレクトリを順にさかのぼって探されます。
ここで言い切っておくと、Claudeに警告を「直させる」と「消させる」を区別しないと、hookは形骸化します。警告が出たときに最短で緑にする手は、disableを足すことです。そこで、CLAUDE.mdに次のような規約を置いておきます。
## シェルスクリプト
- 編集後は必ず `shellcheck --severity=warning <file>` を通す
- 警告は原則コードで直す。`# shellcheck disable=SCxxxx` は、意図的な書き方に限り、
直前の行に理由を1行書いたうえで使う
- 大文字の変数も参照前に代入されているか確認するhookが機械的に検査し、CLAUDE.mdが「どう直すか」の方針を持つ分担です。
Claudeに検査と修正を繰り返させる
hookを入れない場合でも、プロンプトで同じループを回せます。
scripts/deploy.sh を書いてください。書き終えたら shellcheck --format=gcc で検査し、
SC番号ごとに原因を1行で説明してから直してください。
終了コードが0になるまで繰り返し、無視した警告があれば理由も報告してください。SC番号を説明させる一手間で、Claudeが警告を読み飛ばしてdisableで済ませていないかを、人間がレビューで確認できます。
効かないケースと落とし穴
- Bashツール経由の書き込み:
Edit|Writeのhookは、Bashのコマンドや外部のプロセスが同じファイルを書き換えたときには動きません。cat > script.sh <<EOFのような生成は拾えないため、その場合はファイル変更を契機にするFileChangedや、Claudeによる明示的な検査コマンドが要ります - 拡張子の無いスクリプト: スクリプトを
.shに限定しているので、拡張子の無い実行ファイルは対象外です。シェルの種類は、shellディレクティブ、shebang、拡張子の順に推測されます。shebangの無い読み込み専用ファイルは、shell=bashのようなディレクティブか-s bashで教えます。-sで指定できるのはsh、bash、dash、ksh、busyboxです source先の検査: 既定では、コマンドラインで渡したファイルしか追いません。読み込み先まで検査するには-x(--external-sources)か、.shellcheckrcのexternal-sources=trueを使います- dialect依存の警告: shebangが
#!/bin/shだと、(( ))のようなbashの機能に移植性の警告が付きます。bashで書かせるなら、shebangを#!/bin/bashにそろえておきます - バージョン差: 公式READMEは、新しいバージョンが新しい警告を出して突然ビルドが壊れるのを避けるため、ShellCheckのバージョンを固定して入れることを勧めています。hookでもCIでも同じ版にそろえると、手元と結果がずれません
ShellCheckは静的解析なので、スクリプトを実行したときの挙動までは保証しません。ステージング環境でのドライランや、テストは別に必要です。
まとめ
ShellCheckは、クォート漏れ、cdの失敗、read -rの欠落、終了コードの隠れ方など、シェルスクリプトの定番の落とし穴をSC番号で指せる道具です。Claude Codeでは、PostToolUseのhookで編集直後に走らせ、指摘が残ったときだけ終了コード2とstderrで返す構成が簡潔です。
運用の肝は、警告を直す方向に誘導することです。disableは理由コメント付きに限る、とCLAUDE.mdに書いておくだけで、hookが警告を黙らせる作業の場にならずに済みます。未設定変数の検出が小文字の変数に限られる点と、Bash経由の書き込みがhookに掛からない点は、導入前に知っておくと戸惑いません。