Claude Media
CwdChangedフックでcdによる作業ディレクトリ変更を検知する

CwdChangedフックでcdによる作業ディレクトリ変更を検知する

cdなどで作業ディレクトリが変わるたびに発火するCwdChangedフック。matcher非対応で必ず発火し、変更自体はブロックできない仕様と、CLAUDE_ENV_FILEでの環境変数の引き継ぎ方を解説します。

CwdChangedフックとは — cdで作業ディレクトリが変わるたびに発火する

CwdChangedは、メインの会話でシェルコマンドが作業ディレクトリを変えたときに発火するフックです。典型的にはClaudeがcdを実行した瞬間で、公式ドキュメントもこの例を挙げています。用途は環境変数の再読み込みやプロジェクト固有のツールチェーン切り替え、セットアップスクリプトの自動実行で、direnvのようにディレクトリごとに環境を切り替えるツールを自前で再現する土台になります。Bashコマンド実行後の作業ディレクトリ追跡には、Claude Code内部で完結する別の仕組みもあり、Windowsでこの追跡用の一時ファイルが消えずに残っていた不具合はその副作用でした。

このフックの立ち位置は「ディレクトリ移動を止める」ではなく「移動した事実を検知して反応する」点にあります。作業ディレクトリの変更自体はフックが起動する前にすでに完了しており、CwdChangedは事後の通知イベントです。ここは同じ「standalone async」区分のDirectoryAddedフックと同じ設計思想で、Claude Codeは「起きたことに反応させる」フックと「起きる前に止める」フックを明確に分けています。

matcherに対応しない — 常に発火する

CwdChangedにはmatcherがありません。設定でmatcherフィールドを書いても無視され、作業ディレクトリが変わるたびに例外なく発火します。Bashツールの呼び出し単位で見るかぎり、cd dir1 && cd dir2のように1回のコマンドで複数回ディレクトリが変わっても、その回数だけ発火すると考えるのが安全です。

この「絞り込めない」設計は他の環境系フックと対照的です。SessionStartはセッションの開始理由(startupresumeclearなど)で絞り込めますし、DirectoryAddedは追加経路(slash_commandregister_repo_rootか)で絞り込めます。CwdChangedだけは常に全件発火するので、特定のディレクトリだけに反応させたい場合はフックスクリプト側でnew_cwdを見て早期リターンする実装が必須になります。

入力フィールド — old_cwdnew_cwd

CwdChangedフックは共通の入力フィールドに加えてold_cwdnew_cwdを受け取ります。

{
  "session_id": "abc123",
  "transcript_path": "/Users/.../.claude/projects/.../transcript.jsonl",
  "cwd": "/Users/my-project/src",
  "hook_event_name": "CwdChanged",
  "old_cwd": "/Users/my-project",
  "new_cwd": "/Users/my-project/src"
}

cwdは「フック起動時点の作業ディレクトリ」を表す共通フィールドで、CwdChangedでは基本的にnew_cwdと一致します。移動前後を比較するロジックを書くなら、cwdではなくold_cwdnew_cwdを明示的に読むほうが意図が伝わります。

CLAUDE_ENV_FILEで環境変数をBashコマンドへ引き継ぐ

CwdChangedが実用的なのは、CLAUDE_ENV_FILEにアクセスできる4つのフックの1つだからです。この環境変数はシェルスクリプトへのパスを指し、Claude Codeはそのファイルの中身を同じシェルプロセス内の次のBashコマンドが走る前に毎回読み込みます。ファイルへexport文を書き込んでおけば、以降のBashコマンドから見えるようになります。アクセスできるのはSessionStartSetupCwdChangedFileChangedの4イベントだけで、それ以外のフックはこの変数を持ちません。Setup/InstructionsLoadedフックがセッション起動前の一度きりの環境構築を担うのに対し、CwdChangedはセッション中に何度でも起きるディレクトリ移動のたびに環境を差分更新できる点が違います。

書き込んだ変数は永続しません。次のCwdChangedイベントが発火すると、Claude Codeはそこで内容をクリアします。つまり1つ前のディレクトリで読み込んだ環境変数を、新しいディレクトリに移動した後まで引きずる心配はありません。ディレクトリを移るたびに前の環境をリセットしてから新しい環境を積み直す、という前提で設計されています。

以下は.nvmrcがあるディレクトリへ移動したときだけNode.jsのバージョンを切り替え、差分をCLAUDE_ENV_FILEへ書き出すレシピです。

#!/bin/bash
# CwdChanged: .nvmrcがあるディレクトリに入ったらNodeバージョンを切り替える
input=$(cat)
new_cwd=$(echo "$input" | jq -r '.new_cwd')
 
[ -f "$new_cwd/.nvmrc" ] || exit 0
 
before=$(export -p | sort)
cd "$new_cwd" || exit 0
source "$HOME/.nvm/nvm.sh"
nvm use >/dev/null 2>&1
 
if [ -n "$CLAUDE_ENV_FILE" ]; then
  after=$(export -p | sort)
  comm -13 <(echo "$before") <(echo "$after") >> "$CLAUDE_ENV_FILE"
fi
 
exit 0

.nvmrcが無いディレクトリでは即座にexit 0で抜けるので、無関係な移動のたびにnvmを起動するコストを避けられます。CwdChangedはmatcherで絞り込めない以上、こうしたガード節をスクリプトの先頭に置く実装が実質的な絞り込み手段になります。コマンドフックの既定タイムアウトは600秒で、UserPromptSubmitMessageDisplayのように短縮される対象にCwdChangedは含まれていません。重い初期化スクリプトを積んでも比較的余裕がありますが、タイムアウトに達すると出力はそのまま破棄されるので、時間のかかる処理はasync: trueでバックグラウンド化する選択肢も検討する価値があります。

watchPathsでFileChangedの監視対象を動的に切り替える

CwdChangedの出力には、共通のJSON出力フィールドに加えてwatchPathsがあります。これは絶対パスの配列で、FileChangedフックが監視するファイルの動的リストを丸ごと置き換えます。matcher設定に書いたパスはwatchPathsの内容にかかわらず常に監視対象のままで、空配列を返すと動的リストだけがクリアされます。公式ドキュメントは、新しいディレクトリに入った直後は空配列を返すのが典型的な使い方だと説明しています。

この仕組みが効くのは、ディレクトリごとに監視したいファイルが変わる場面です。次の例は、移動先に.envまたは.envrcがあればそれを動的な監視対象として登録し、無ければ前のディレクトリ分の監視をクリアします。

#!/bin/bash
# CwdChanged: 移動先の環境ファイルをFileChangedの動的監視に登録する
input=$(cat)
new_cwd=$(echo "$input" | jq -r '.new_cwd')
 
paths=()
for f in .env .envrc; do
  [ -f "$new_cwd/$f" ] && paths+=("\"$new_cwd/$f\"")
done
 
if [ ${#paths[@]} -eq 0 ]; then
  echo '{"watchPaths": []}'
else
  printf '{"watchPaths": [%s]}\n' "$(IFS=,; echo "${paths[*]}")"
fi

SessionStartも同じwatchPathsを返せるので、セッション開始時点の初期リストはSessionStart、ディレクトリを移るたびの更新はCwdChanged、という役割分担がそのまま公式仕様に沿った書き方になります。FileChanged側の監視対象を動的に増減させたいなら、この2つのイベント以外にwatchPathsを返す経路はありません。

決定権が無い — ディレクトリ移動そのものはブロックできない

CwdChangedにはdecision controlがありません。SetupDirectoryAddedFileChangedと同じ「None」区分で、できるのはログ記録や後始末のような副作用の実行だけです。作業ディレクトリの変更はフックが起動する前にすでに完了しているため、exit codeを2にしても移動を取り消す効果はありません。公式ドキュメントのライフサイクル図でもCwdChangedFileChangedDirectoryAddedと並んで「standalone async」イベントに分類されており、Claude Codeは完了を待たずに次の処理へ進みます。移動先ディレクトリで実行するBashコマンドが、フックの完了より先に走ってしまう可能性は織り込んでおく必要があります。公式ドキュメントの「exit code 2の挙動」一覧でも、CwdChangedは「ブロック不可・stderrをユーザーにだけ表示する」に分類されています。この認識ズレが実際の事故につながった例は、作業ディレクトリを見失う問題の原因と対処法で詳しく扱っています。

Claude CodeがCwdChangedの出力から読み取るのはwatchPathssystemMessageで、continueフィールドは破棄されます。対話セッションではsystemMessageが短いターミナル通知として表示されますが、この文字列はSDKのメッセージストリームには乗りません。SDK経由でこのフックの結果を拾いたい場合は、通知をフックスクリプト側で完結させる必要があります。

サブエージェント内のcdでも発火するか

公式ドキュメントは、設定ファイル・管理ポリシー設定・プラグインに定義したフックがサブエージェント内でも動くと説明しています。サブエージェントがツールを呼び出すと、PreToolUsePostToolUseのようなツールイベントはメインの会話と同じように発火し、入力にはサブエージェントを識別するagent_idagent_typeが加わります。

ところがCwdChangedの説明文が挙げている発火条件は「メインの会話でのシェルコマンド」であり、ツールイベント一般に対する「サブエージェント内でも同じ設定のフックが発火する」という記述と同じ強さでは書かれていません。サブエージェントがBashツールでcdを実行した場合にCwdChangedが発火するかどうかを、公式ドキュメントの字面だけから断定することはできません。サブエージェントの作業ディレクトリ変更まで拾いたい運用を組む場合は、この前提の違いを踏まえて実機で発火の有無を確認してから本番の自動化に組み込むのが安全です。

Claude Codeを動かす場所を問わず同じイベントが発火する

CwdChangedを含むフックイベントは、ターミナルのセッション・IDE拡張・デスクトップアプリ・Claude Code on the webのどれで動かしていても同じように発火します。ローカルのターミナルで組んだCLAUDE_ENV_FILE連携のスクリプトは、実行環境を変えても書き直す必要がありません。ただしClaude Code on the webのクラウドセッションはローカルの~/.claude/settings.jsonを読み込まないため、個人設定に置いたフックはそちらには届かず、リポジトリ内の設定か組織のサーバー管理設定に置いたフックだけが対象になります。

環境系フックの使い分け早見表

作業ディレクトリや環境の変化に反応するフックはCwdChanged以外にも複数あり、発火タイミングとdecision controlの有無で役割が分かれます。

フック発火タイミングmatcherdecision control
CwdChanged発火タイミングcdなどで作業ディレクトリが変わった直後matcher非対応(常に発火)decision controlなし
SessionStart発火タイミングセッション開始時(起動・resume・clear・compact・fork)matcherありdecision controlコンテキスト追加のみ
Setup発火タイミング--init-only/-p --init/-p --maintenance実行時のみmatcherありdecision controlなし
DirectoryAdded発火タイミング/add-dirregister_repo_rootでディレクトリ追加直後matcherありdecision controlなし
FileChanged発火タイミング監視対象ファイルがディスク上で変化した時matcherありdecision controlなし

CwdChangedが唯一matcherを持たない点が際立ちます。特定のディレクトリだけを対象にしたいなら、フックスクリプトの内側でnew_cwdを判定する以外に手段がありません。

よくあるつまずき

  • exit 2でcdをブロックできると考える: 移動はフック起動前にすでに完了しているので、exit codeを変えても効果はありません。ブロックしたい操作があるなら、cd自体をPreToolUseBashマッチャーで扱う設計に切り替える必要があります
  • matcherで対象ディレクトリを絞れると思い込む: CwdChangedはmatcher非対応で、設定に書いても無視されます。絞り込みはスクリプト内でnew_cwdを見て行います
  • CLAUDE_ENV_FILEに書いた変数が次のディレクトリまで残ると期待する: 次のCwdChangedが発火した時点でClaude Codeが内容をクリアします。ディレクトリごとに環境変数を積み直す前提で書く必要があります
  • systemMessageがSDKのメッセージストリームに乗ると思い込む: 対話セッションのターミナル通知にしか表示されません。SDK統合で通知を拾いたいなら、フックスクリプト側で別途送信する実装が必要です
  • サブエージェント内のcdでも同じように発火すると思い込む: 前節のとおり、公式ドキュメントの書き方はツールイベント一般とは異なります。断定せず実機で確認してから運用に組み込みます

まとめ

CwdChangedcdによる作業ディレクトリの変更を検知するための標準的な入口です。matcherが無いぶん常に発火し、ディレクトリの移動自体を止める手段も持ちませんが、CLAUDE_ENV_FILEへの書き込みでBashコマンドに環境変数を引き継げ、watchPathsを返せばFileChangedの監視対象を移動先に合わせて動的に切り替えられます。direnvのようなディレクトリ単位の環境切り替えをClaude Code内で再現したいときの、事実上の唯一の接続点です。

ディレクトリを跨いでツールチェーンや環境変数を切り替える運用を組むなら、セッション開始時の初期化はSessionStart、セッション途中の移動はCwdChangedという役割分担を崩さないことが、実装をシンプルに保つ近道です。

この記事を共有:XはてブLinkedIn