Claude Media
Claude Codeでbashをデバッグする — set -xとPS4で行番号付きトレース

Claude Codeでbashをデバッグする — set -xとPS4で行番号付きトレース

bashスクリプトの不具合をClaude Codeに直させるとき、素のset -xでは行が分かりません。PS4に行番号と関数名を入れ、失敗の直前だけを切り出して渡す手順を、実行した出力つきで書きます。

「このシェルスクリプトが途中でおかしくなる。直して」とだけ頼むと、Claude Codeはスクリプトを読んで原因を推測します。推測が当たる日もありますが、実行時に何が起きたかを渡したほうが、直しは安定します。bashでその材料になるのが set -x のトレースです。

ただし素の set -x は、どの行のどの関数で実行されたかが出ません。この記事では、PS4 に行番号と関数名を入れる方法、数百行のトレースから失敗の直前だけを切り出す方法、Claude Codeへの渡し方を、手元で動かした出力つきで扱います。出力はmacOSのbash 3.2.57で取りました。

素のset -xは行番号が出ない

set -x(または bash -x script.sh)は、実行する各コマンドを展開後の形で標準エラー出力に書き出します。先頭には PS4 の値が付き、既定は + です。行番号もファイル名も付きません。

#!/usr/bin/env bash
set -eu
greet() { local n=$1; echo "hello $n"; }
count_files() {
  local dir=$1
  ls "$dir" | wc -l
}
set -x
greet world
total=$(count_files ./nodir)
echo "total=$total"

既定の PS4 で動かしたトレースは、次の形になります。

+ greet world
+ local n=world
+ echo 'hello world'
++ count_files ./nodir
++ local dir=./nodir
++ ls ./nodir
++ wc -l
ls: ./nodir: No such file or directory
+ total='       0'
+ echo 'total=       0'

何が起きたかは読めます。ただし、トレースの行をスクリプトの何行目に戻せばよいか、手がかりがありません。短いスクリプトなら見当が付きますが、数百行の運用スクリプトでは、Claude Codeも人間も grep で該当箇所を探すところから始めることになります。

先頭の + が ++ になっている行にも意味があります。コマンド置換 $(...) の中で実行された行は、+ が1つ増えます。入れ子の深さがそのまま見えるので、PS4 を替えても先頭の + は残しておくのが定石です。

PS4に行番号・ファイル名・関数名を入れる

PS4 は変数なので、set -x の前に代入するだけで形を変えられます。bashは PS4 の中身も展開してからトレース行の頭に付けるため、$LINENO などが使えます。

PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
要素出る内容
${BASH_SOURCE##*/}出る内容実行中のスクリプトのファイル名(ディレクトリは除く)
${LINENO}出る内容実行中の行番号
${FUNCNAME[0]:-main}出る内容関数内なら関数名、関数の外なら main

FUNCNAME は関数の外では空です。:-main で既定値を補っているのは、set -u を併用しているスクリプトで未定義変数のエラーを避けるためです。

先ほどのスクリプトの set -eu の次の行(3行目)にこの PS4 を入れ、以降の行を1つ下げて動かすと、トレースは次のように変わります。

+ deploy.sh:10:main: greet world
+ deploy.sh:4:greet: local n=world
+ deploy.sh:4:greet: echo 'hello world'
++ deploy.sh:11:main: count_files ./nodir
++ deploy.sh:6:count_files: local dir=./nodir
++ deploy.sh:7:count_files: ls ./nodir
++ deploy.sh:7:count_files: wc -l
ls: ./nodir: No such file or directory
+ deploy.sh:11:main: total='       0'
+ deploy.sh:12:main: echo 'total=       0'

ls が失敗したのは count_files の7行目、と読み取れるようになりました。呼び出し元は11行目です。「deploy.sh の7行目の ls が失敗しているのに、スクリプトは最後まで進んでいる」と、場所つきでClaude Codeに伝えられます。

トレースで見るべき3つの手がかり

行番号が付いたら、次はトレースのどこを読むかです。Claude Codeに読ませるときも、観点を先に伝えると的が絞れます。

トレース行の見た目には、引用符の有無が出ます。変数をクォートしていない呼び出しは、展開後に空白で割れて別々の引数になります。f="a b.txt" を使った次のスクリプトで確かめます。

#!/usr/bin/env bash
PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '
f="a b.txt"
set -x
ls -l $f
ls -l "$f"
+ q.sh:5: ls -l a b.txt
ls: a: No such file or directory
ls: b.txt: No such file or directory
+ q.sh:6: ls -l 'a b.txt'

クォートなしの5行目は a と b.txt の2引数に割れ、クォートありの6行目は 'a b.txt' と引用符つきで1引数になっています。トレース上で引数が引用符で囲まれているかどうかが、そのまま単語分割の有無の証拠です。

空の変数は、空のまま展開されます。rm -rf "$dir"/* のようなコマンドで dir が空なら、トレースには rm -rf /* に近い形が出ます。実行前に気づく手段にはなりませんが、事故の後に何が起きたかをたどる材料にはなります。set -u が止めるのは未定義の変数の参照で、空文字が入った変数は素通しします。空のままでは困る変数は ${dir:?} のように書くと、空でも止まります。

++ が増えた行は、コマンド置換の中です。先ほど見たとおり、$(...) の中の失敗は呼び出し元の行より深い階層に出ます。親の行だけを見て原因を探すと、見当違いの場所を直すことになります。

これらを CLAUDE.md や依頼文に「引数の引用符、空の展開、++ の深さの3点を見て」と書いておくと、トレースを読む観点が固定されます。

set -vとの違い

似たオプションに set -v があります。set -x が展開後のコマンドを出すのに対し、set -v はスクリプトの行を読み込んだままの文字列で出します。name=world のあとに echo "hello $name" を実行すると、-v は echo "hello $name" と変数が展開されない形で、-x は echo 'hello world' と展開後の形で出ます。bash -vx で両方を有効にすると、元の行と展開後の行が交互に並びます。

デバッグで知りたいのは多くの場合、実際に渡された値です。展開後の形が出る set -x を主に使い、-v は元の行を並べて見たいときの補助にします。

長いトレースから、失敗の直前だけを渡す

実際のスクリプトでは、トレースが数百行になります。全部を貼ると、コンテキストを関係のない行が占め、肝心の失敗行が埋もれます。次の順で絞ります。

手順

トレースを絞って渡す順番

  1. 1

    トレースを端末ではなくファイルに落とす

    標準エラー出力をファイルへ向けます。標準出力は別に保存しておくと、あとで突き合わせられます。

  2. 2

    失敗の合図をトレースに混ぜる

    trap ... ERR でエラー時に1行出しておくと、トレース内の目印になります。

  3. 3

    目印の手前だけを切り出す

    tail や sed で、目印の前の数行から末尾までだけを取り出します。

200回ループを含むスクリプトで試します。途中で存在しないファイルをコピーして失敗させています。

#!/usr/bin/env bash
set -euo pipefail
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
trap 'echo "ERR exit=$? line=$LINENO" >&2' ERR
step() { echo "step $1"; }
for i in $(seq 1 200); do :; done
step one
cp ./missing.txt /tmp/out.txt
step two
bash -x run.sh 2>trace.txt >out.txt; echo "exit=$?"
wc -l trace.txt
tail -n 4 trace.txt
exit=1
     410 trace.txt
+ run.sh:8:main: cp ./missing.txt /tmp/out.txt
cp: ./missing.txt: No such file or directory
++ run.sh:8:main: echo 'ERR exit=1 line=8'
ERR exit=1 line=8

トレースは410行ありましたが、失敗を説明するのは末尾の4行です。ループの200回分は、1周ごとに2行ずつ出ていました。原因が見つかった後の行まで貼る必要はありません。

絞り方の使い分けを表にします。

状況渡し方向く理由
最後のコマンドで落ちた渡し方tail -n 20 trace.txt向く理由set -e で止まったなら、失敗行は末尾にある
落ちずに誤った結果が出た渡し方疑わしい関数だけ set -x と set +x で挟む向く理由範囲を絞れば、そもそも数十行で済む
目印が複数ある渡し方grep -n 'ERR exit' trace.txt で行番号を出し、その前後を sed -n で切る向く理由最初の失敗だけを渡せる

範囲を挟む方法は、トレースを出す側で絞る手段です。

set -x
process_batch "$input"
set +x

set +x 自体の行はトレースに残りますが、その後の行は出ません。

落とし穴

BASH_XTRACEFDは古いbashでは効かない

トレースだけを別ファイルに出したいときは、exec 5>trace.log のあとに BASH_XTRACEFD=5 を指定する方法があります。これはbash 4.1以降の機能です。macOS標準のbash 3.2.57で試したところ、指定は無視されてトレースは標準エラー出力に出たままで、trace.log は空でした。新しいbashが入っていない環境では、2>trace.txt のリダイレクトで分ける形が確実です。

Claude Codeへの渡し方

渡し方は2通りあります。

1つ目は、セッション内でClaude Codeにコマンドを実行させる方法です。公式のワークフロー集は、不具合を直すときは再現コマンドを伝え、再現が毎回か断続的かも伝えるよう勧めています。トレース取得の手順を依頼文に含めれば、その再現コマンドをそのまま渡せます。

deploy.sh の7行目あたりで ls が失敗しているのに、スクリプトが最後まで進んでしまう。
次の手順で行番号付きのトレースを取って、失敗した行とその直前の10行を読んでから原因を教えて。
 
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' bash -x deploy.sh 2>trace.txt
tail -n 30 trace.txt
 
直すのは原因が分かってからにして。修正後は同じ手順でトレースを取り直し、失敗が消えたことを出力で見せて。

最後の1文が効きます。修正の前後で同じトレースを並べれば、直った根拠が人間にも見えます。

2つ目は、トレースを標準入力で渡す方法です。ベストプラクティスのページには、コマンド出力を cat error.log | claude -p "what's going on here" のように流し込む例があります。絞ったトレースもそのまま流せます。

tail -n 30 trace.txt | claude -p "このbashトレースで最初に失敗している行と原因を、行番号つきで答えて"

標準入力の上限は10MBで、超えるとエラー終了します。トレースが大きいときは、ファイルに書いてパスを依頼文に書くほうが安全です。非対話モードの詳しい使い方はclaude -pのガイドにあります。

毎回書かないために、規約をCLAUDE.mdに置く

行番号付きの PS4 は、毎回の依頼文に書くより CLAUDE.md に1回書いておくほうが漏れません。

## シェルスクリプトのデバッグ
- トレースは `set -x` を直書きせず、次の形で取る
  `PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' bash -x <script> 2>trace.txt`
- トレースは全文を読まず、`tail -n 30 trace.txt` から始める
- 直す前に失敗行を行番号つきで報告し、直した後は同じ手順で取り直す
- 検証が終わったら、`set -x` の行を残していないか `grep -n 'set -x'` で確かめる

bash -x で外から付ける形にしておけば、スクリプト本体を書き換えずに済み、set -x の消し忘れも起きません。ただし CLAUDE.md は従うよう促す文脈であって、強制力のある仕組みではありません。確実に防ぎたいときは、編集後にフックで検査する構成を選びます。シェルスクリプトの静的検査をフックに載せる方法はShellCheckのフックの記事にあります。

トレースを渡す前に確かめること

トレースは、スクリプトが実際に扱った値をそのまま展開して書きます。上の例でも total=' 0' のように、変数の中身がトレース行に出ていました。トークンやパスワードを変数で扱うスクリプトでは、それらの値もトレースに出ます。Claude Codeに渡す前に、grep -n -i 'token\|password\|secret' trace.txt のような確認を挟むか、機密を扱う区間だけ set +x で外してください。

もう1点、PS4 の書き方はbashの流儀です。macOSの既定シェルはzshで、bash構文をzshで走らせて失敗するケースもあります。起動に使うシェルとスクリプトの先頭行(#!/usr/bin/env bash)が揃っているかは、トレースを取る前に確認します。この食い違いはClaude Codeがzshでbash構文を書いて失敗する記事で扱っています。

トレースで原因を確定したあとは、同じ失敗を再発させないテストを足す段階です。bats で異常系を書く手順はbatsのシェルスクリプトテストにまとめています。

まとめ

素の set -x は何が実行されたかを教えますが、どこで実行されたかは教えません。PS4 に ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main} を入れるだけで、トレースの各行がスクリプトの行に戻せます。渡すときは全文ではなく失敗の目印の手前だけに絞り、修正の前後で同じ手順のトレースを並べて直った根拠にします。macOS標準のbash 3.2では BASH_XTRACEFD が効かない点と、トレースに秘密の値が出る点は、先に押さえておく価値があります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/common-workflows
  2. code.claude.com/docs/en/best-practices
  3. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn