CwdChangedフックでcdによる作業ディレクトリ変更を検知する
Claude Codeで作業ディレクトリを変える方法(/cd・/add-dir・cd)と、変更を検知するCwdChangedフック。matcher非対応で変更をブロックできない仕様と、CLAUDE_ENV_FILEの使い方も解説します。
CwdChangedフックとは — cdで作業ディレクトリが変わるたびに発火する
CwdChangedは、メインの会話でシェルコマンドが作業ディレクトリを変えたときに発火するフックです。典型的にはClaudeがcdを実行した瞬間で、公式ドキュメントもこの例を挙げています。用途は環境変数の再読み込みやプロジェクト固有のツールチェーン切り替え、セットアップスクリプトの自動実行です。direnvのようにディレクトリごとに環境を切り替える仕組みを、自前で再現する土台になります。
Bashコマンド実行後の作業ディレクトリ追跡には、Claude Code内部で完結する別の仕組みもあります。Windowsでこの追跡用の一時ファイルが消えずに残っていた不具合は、その副作用でした。
このフックの立ち位置は「ディレクトリ移動を止める」ではなく「移動した事実を検知して反応する」点にあります。作業ディレクトリの変更はフックが起動する前にすでに完了していて、CwdChangedは事後の通知イベントです。同じく「standalone async」区分のDirectoryAddedフックと、この点は共通しています。
Claude Codeで作業ディレクトリを変更する3つの方法
作業ディレクトリの変え方は、誰が何を動かすかで3通りに分かれます。CwdChangedが反応するのは、このうちClaudeがメインの会話で実行するcdです。
| 方法 | 動かす人 | 効果 |
|---|---|---|
/cd <path> | 動かす人あなた | 効果セッションを新しいディレクトリへ移し、会話は引き継ぐ |
/add-dir <path> | 動かす人あなた | 効果今のディレクトリはそのまま、アクセス先を追加する |
Bashでのcd | 動かす人Claude | 効果プロジェクト内か追加ディレクトリ内なら、次のBashコマンドにも引き継がれる |
/cdは新しいディレクトリのCLAUDE.mdやプロジェクト設定、フックを読み込み直します。/add-dirは追加先の.claude/設定のほとんどを読みません。/add-dirの実行後に発火するのはDirectoryAddedフックです。/cdでCwdChangedが発火するかは、公式ドキュメントに記載がありません。
サブエージェント内のcdは、後続のBashコマンドへ引き継がれません。Bashコマンドのたびに元のディレクトリへ戻したいときは、環境変数CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIRを設定します。
settings.jsonへの書き方 — matcherは書かない
CwdChangedにmatcherはありません。matcherフィールドを書いても絞り込みには使えず、作業ディレクトリが変わるたびに発火します。設定はhooks配下にイベント名を置き、type: "command"のフックを並べるだけです。
{
"hooks": {
"CwdChanged": [
{
"hooks": [
{
"type": "command",
"command": "/path/to/on-cwd-changed.sh"
}
]
}
]
}
}この「絞り込めない」設計は、他の環境系フックと対照的です。SessionStartは開始理由(startup・resume・clear・compact・fork)で、DirectoryAddedは追加経路(slash_commandかregister_repo_rootか)で絞り込めます。CwdChangedだけは全件発火するので、特定のディレクトリにだけ反応させたいなら、スクリプトの先頭でnew_cwdを見て早期リターンする実装が必須です。
入力フィールド — old_cwdとnew_cwd
CwdChangedフックは共通の入力フィールドに加えて、old_cwdとnew_cwdを標準入力のJSONで受け取ります。
{
"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は「フック起動時点の作業ディレクトリ」を表す共通フィールドで、この例ではnew_cwdと同じ値です。移動前後を比較するロジックを書くなら、cwdではなくold_cwdとnew_cwdを明示的に読むほうが意図が伝わります。
CLAUDE_ENV_FILEで環境変数をBashコマンドへ引き継ぐ
CwdChangedが実用的なのは、CLAUDE_ENV_FILEにアクセスできる4つのフックの1つだからです。アクセスできるのはSessionStart・Setup・CwdChanged・FileChangedの4イベントだけで、それ以外のフックはこの変数を持ちません。
この環境変数はシェルスクリプトへのパスを指します。Claude Codeはそのファイルの中身を、同じシェルプロセス内の次のBashコマンドが走る前に毎回読み込みます。流れは次のとおりです。
CLAUDE_ENV_FILEが環境を引き継ぐ流れ
- 1
cdで作業ディレクトリが変わる
Claudeが実行したBashコマンドの中で
cdが走り、作業ディレクトリが変わります。 - 2
CwdChangedフックが発火する
フックは
new_cwdを読み、環境を切り替えて、増えた変数をexport文としてCLAUDE_ENV_FILEへ書き足します。 - 3
次のBashコマンドが変数を受け取る
書き込んだ内容は、次のBashコマンドが走る前に読み込まれます。
- 4
次のCwdChangedで内容が消える
次の
CwdChangedが発火した時点で、Claude Codeがそこまでの内容をクリアします。
前のディレクトリで読み込んだ環境変数を、新しいディレクトリまで引きずる心配はありません。ディレクトリを移るたびに前の環境をリセットして、新しい環境を積み直す前提です。Setup/InstructionsLoadedフックがセッション起動前の一度きりの環境構築を担うのに対し、CwdChangedはセッション中に何度でも起きる移動のたびに環境を更新できます。
以下は.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を起動するコストを避けられます。matcherで絞れない以上、このガード節が実質的な絞り込み手段です。
コマンドフックの既定タイムアウトは600秒です。UserPromptSubmitは30秒、MessageDisplayは10秒に短縮されますが、CwdChangedは短縮の対象に含まれません。タイムアウトに達したフックは取り消され、出力も破棄されます。
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このスクリプトに疑似入力を流すと、次の出力が得られます。.envと.envrcがあるappディレクトリと、どちらも無いemptyディレクトリで試した結果です。
{"hook_event_name":"CwdChanged","old_cwd":"/tmp/x","new_cwd":"/work/proj/app"}
→ {"watchPaths": ["/work/proj/app/.env","/work/proj/app/.envrc"]}
{"hook_event_name":"CwdChanged","old_cwd":"/tmp/x","new_cwd":"/work/proj/empty"}
→ {"watchPaths": []}ここで注意したいのは、watchPathsだけではファイル監視が始まらない点です。Claude Codeは、監視するファイルを名指しするものがあるときにだけウォッチャーを起動します。そのため、FileChanged側にmatcherで1つ以上のファイル名を書いたグループを置くか、SessionStartかCwdChangedがwatchPathsを返す必要があります。
動的に増えたパスで変更を受け取るグループには、matcherを省略します。matcherを省略すると、監視中のすべてのファイルに一致し、しかも監視リストには何も追加されません。"*"と書くと全ファイルに一致しますが、*という名前のファイルとして監視リストに登録されてしまいます。
{
"hooks": {
"CwdChanged": [
{ "hooks": [{ "type": "command", "command": "/path/to/watch-env-files.sh" }] }
],
"FileChanged": [
{ "hooks": [{ "type": "command", "command": "/path/to/reload-env.sh" }] }
]
}
}watchPathsを返して監視リストを作れるフックのうち、セッション開始時の初期化はSessionStart、ディレクトリを移るたびの更新はCwdChangedが受け持ちます。
移動そのものはブロックできない
CwdChangedにはdecision controlがありません。Setup・DirectoryAdded・FileChangedと同じ区分で、できるのはログ記録や後始末のような副作用の実行だけです。作業ディレクトリの変更はフックが起動する前にすでに完了しているので、exit codeを2にしても移動は取り消せません。公式ドキュメントのexit code 2の一覧でも、CwdChangedは「ブロック不可・stderrをユーザーにだけ表示する」に分類されています。ブロックしたい操作があるなら、cdを含むBashコマンドをPreToolUseのBashmatcherで扱います。
ライフサイクル図では、CwdChangedはFileChangedやDirectoryAddedと並んで「standalone async」イベントとして描かれています。この区分からは、フックの完了より先に移動先で次のBashコマンドが走りうると読めます。その前提でスクリプトを組む選択肢があります。この認識ズレが実際の事故につながった例は、作業ディレクトリを見失う問題の原因と対処法で詳しく扱っています。
出力のうちClaude Codeが読み取るのはwatchPathsとsystemMessageで、continueは破棄されます。対話セッションではsystemMessageが短いターミナル通知として表示されますが、SDKのメッセージストリームには届きません。SDK経由で結果を拾いたい場合は、通知をスクリプト側で完結させます。
async: trueを付けたコマンドフックには、timeoutが適用されません。ログ送信のように時間のかかる副作用は、この指定でバックグラウンドへ逃がす選択肢があります。ただし非対話モードの-pでは、実行中の非同期フックはセッションの終了時にcancelledとして打ち切られます。
サブエージェント内のcdでも発火するか
設定ファイル・管理ポリシー設定・プラグインに定義したフックは、サブエージェント内でも動きます。サブエージェントがツールを呼び出すと、PreToolUseやPostToolUseのようなツールイベントはメインの会話と同じように発火し、入力にはサブエージェントを識別するagent_idとagent_typeが加わります。
一方、CwdChangedの説明文が挙げている発火条件は「メインの会話でのシェルコマンド」です。ツールイベント一般に書かれている「サブエージェント内でも同じ設定のフックが発火する」という記述とは、書き方が違います。サブエージェントがBashツールでcdしたときにCwdChangedが発火するかは、公式ドキュメントに記載がありません。サブエージェントの移動まで拾いたい運用では、発火の有無を実機で確かめてから自動化に組み込む手があります。
Claude Codeを動かす場所を問わず同じイベントが発火する
フックイベントは、ターミナルのセッション・IDE拡張・デスクトップアプリ・Claude Code on the webのどれで動かしても、同じように発火します。ローカルで組んだCLAUDE_ENV_FILE連携のスクリプトは、実行環境を変えても書き直す必要がありません。
ただしClaude Code on the webのクラウドセッションは、ローカルの~/.claude/settings.jsonを読み込みません。個人設定に置いたフックはそちらには届かず、リポジトリ内の設定などクラウドセッションへ引き継がれる設定に置いたフックだけが動きます。
環境系フックの使い分け早見表
作業ディレクトリや環境の変化に反応するフックは複数あり、発火タイミングとdecision controlの有無で役割が分かれます。
| フック | 発火タイミング | matcherの対象 | decision control |
|---|---|---|---|
CwdChanged | 発火タイミングcdなどで作業ディレクトリが変わった直後 | matcherの対象非対応(常に発火) | decision controlなし |
SessionStart | 発火タイミングセッション開始時(起動・resume・clear・compact・fork) | matcherの対象開始理由 | decision controlコンテキスト追加のみ |
Setup | 発火タイミング--init-only/-p --init/-p --maintenance実行時のみ | matcherの対象起動フラグ(init・maintenance) | decision controlなし |
DirectoryAdded | 発火タイミング/add-dirやregister_repo_rootでディレクトリ追加直後 | matcherの対象追加経路 | decision controlなし |
FileChanged | 発火タイミング監視対象ファイルがディスク上で変化した時 | matcherの対象監視するファイル名(.envrc|.envなど) | decision controlなし |
FileChangedのmatcherは絞り込みでもあり、監視対象の指定でもあります。この表の中でCwdChangedだけがmatcherの概念を持たないので、特定のディレクトリだけを対象にしたいなら、スクリプトの内側でnew_cwdを判定する以外に手段がありません。
まとめ
ディレクトリを移るたびに環境変数とFileChangedの監視先を両方更新できるのはCwdChangedです。起動時に一度だけ済ませればよい設定ならSessionStartで足ります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。