Claude CodeのPython hookでWindowsの日本語が文字化けする原因と対策
Windowsの日本語環境でPythonのhookが日本語を読み違える原因と、stdinのバイト列読み・stdoutの再設定・PYTHONUTF8での対策、PowerShell経由のBOMの落とし穴をまとめます。
Claude CodeのhookをPythonで書き、Windowsの日本語環境で動かすと、日本語を含む入力が壊れて届くことがあります。hookのstdinにはUTF-8のJSONが流れてきますが、Pythonは既定でWindowsのANSIコードページ(日本語環境ではcp932)として読むためです。しかも例外が出ないまま、hookが何もしない状態になりがちです。原因と、sys.stdin.bufferで読む直し方、PYTHONUTF8=1で揃える方法をまとめます。
日本語が壊れる仕組み
hookのPython文字化けとは、Claude Codeが渡すUTF-8のバイト列を、Pythonが別の文字コードとして解釈して起きる現象です。GitHubのissue #96285では、PostToolUse hookのstdinを実際に捕捉した結果が報告されています。Writeで書いた合言葉はさくらは、\uエスケープではなく生のUTF-8バイト(e5 90 88 e8 a8 80 ...)で届いたとのことです。
一方、Windowsでjson.load(sys.stdin)と書くと、sys.stdinはUTF-8モードが有効でない限りANSIコードページでデコードされます。報告者の環境はWindows 11・日本語ロケール(コードページ932)・Claude Code 2.1.280・Python 3.11です。この組み合わせで、非ASCIIの文字列は文字化けします。
macOSやLinuxではstdinの既定がUTF-8なので、同じスクリプトが問題なく動きます。手元のMacで試したhookをWindowsに持ち込んで初めて壊れる、という順序になります。
エラーが出ないまま起きた3つの失敗
報告者が自作プラグインで踏んだ症状は3つです。どれもエラーは見えませんでした。
- 書き込んだ行を記録するPostToolUse hookが、日本語を含むファイルの記録に失敗した。デコード結果に単独のサロゲート文字が混じり、書き戻せなかった
git reset --hardを止めるPreToolUseの見張りが、日本語名のフォルダのリポジトリでは働かなかった。cwdが化けてgitがそこで動けず、コマンドが素通りした- ステータスライン用のスクリプトが、日本語のモデル名とパスを化けさせた
2つ目は影響が重い例です。守るためのhookが、守る対象のフォルダ名が日本語だというだけで機能を失います。hookは失敗しても処理を続ける作りにしてあることが多く、壊れたことに気づく手がかりがありません。
公式ドキュメントが触れていない点
hooksのリファレンスには、コマンドhookがJSONをstdinで受け取り、終了コードとstdoutで結果を返すと書かれています。入力例も載っています。ただし、そのJSONが何のエンコーディングかを述べた記述はありません。issue #96285は、この点を「欠けているドキュメント」として起票されたものです。
起票者の提案は2つです。1つは、Hook inputの節に「入力はUTF-8のJSONで、WindowsのPythonはsys.stdin.bufferを読んでUTF-8でデコードするか、PYTHONUTF8=1で実行する」という一文を足すこと。もう1つは、Claude Code側がhookのプロセスにPYTHONUTF8=1を渡し、従来のjson.load(sys.stdin)のままでも正しく動くようにすることです。issueはopenで、どちらの案も議論の段階です。つまり、書き手のほうで備えるしかありません。
手元のMacでも再現する
Windows機がなくても、文字コードを指定すれば同じ型の失敗を観察できます。次のスクリプトは、stdinのバイト列をUTF-8とcp932の2通りにデコードして並べます。
# probe.py
import sys
raw = sys.stdin.buffer.read()
print("bytes :", raw[:40])
print("utf-8 :", repr(raw.decode("utf-8")))
print("cp932 :", repr(raw.decode("cp932", errors="replace")))printf '{"cwd":"/work/日本語","msg":"合言葉はさくら"}' \
| python3 probe.pyutf-8の行は日本語がそのまま読め、cp932の行は同じバイト列が別の文字に読み替えられて化けます。Windowsのsys.stdin.read()が内部でやっているのは、後者と同じ読み替えです。なお、これはMac上の疑似再現です。Windows上のClaude Codeから実際に起動された状態の確認は、後述の「実際にWindowsで効いているかを確かめる」のhookで行います。
入力側の直し方
直し方は、テキストストリームを経由せずバイト列で受け取り、UTF-8を明示してデコードすることです。issueの追記では、これが全Pythonバージョンで通用する形として示されています。
import json
import sys
raw = sys.stdin.buffer.read()
data = json.loads(raw.decode("utf-8"))json.load(sys.stdin)を1行置き換えるだけで済みます。cwdやtool_input.file_path、tool_input.contentに日本語が入っても、そのまま読めます。
BOMが付いて届く場合
PowerShell 5.1経由でhookにJSONを渡す構成では、さらにBOMの問題が加わります。同じissueでWindows 11・Python 3.12.8(コードページ1251)を使った別の報告者が、次の3点を測っています。
- PowerShell 5.1の
$OutputEncodingは既定でASCIIです。'{"t":"café"}' | python read_stdin.pyと流すと、éはパイプの時点で?に置き換わります。hook側のデコードでは取り戻せません $OutputEncoding = [System.Text.Encoding]::UTF8で上流を直すと、今度は先頭にBOM(\xef\xbb\xbf)が付きます。json.loadsはExpecting value: line 1 column 1 (char 0)で失敗し、不正なJSONに見えますが原因はエンコーディングです- BOMなしにするには
[System.Text.UTF8Encoding]::new($false)を使います
受け手の側でBOMを許すなら、デコードをutf-8-sigにします。BOMがあってもなくても読め、不正なUTF-8は引き続き弾かれます。
data = json.loads(sys.stdin.buffer.read().decode("utf-8-sig"))hookの既定シェルは、WindowsでGit Bashがあればbash、なければPowerShellです。どの経路で起動しているかは、hookのshellフィールドで決まります。hook全体の設定はClaude Code Hooks完全ガイドで確認できます。
出力側も揃える
壊れるのは入力だけではありません。同じ報告者が、SessionStart hookが日本語入りのadditionalContextを出力するケースも測っています。モデルに合言葉を引用させて確かめた結果は次のとおりです。
| hookの出力方法 | モデルに届いた内容 |
|---|---|
print(json.dumps(obj, ensure_ascii=False)) | モデルに届いた内容文字化け(U+FFFDが16個) |
print(json.dumps(obj))(非ASCIIは\uエスケープ) | モデルに届いた内容日本語のまま届く |
sys.stdout.reconfigure(encoding="utf-8")のあとensure_ascii=False | モデルに届いた内容日本語のまま届く |
json.dumpsの既定はensure_ascii=Trueで、出力が純ASCIIになります。そのため、既定のまま書けばWindowsでも安全です。ensure_ascii=Falseで人間に読みやすくしたくなったときだけ、stdoutのUTF-8化が必要になります。
import json
import sys
sys.stdout.reconfigure(encoding="utf-8")
out = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "合言葉はさくら並木です",
}
}
print(json.dumps(out, ensure_ascii=False))reconfigureはPython 3.7以降のテキストストリームのメソッドで、読み込み済みのデータがあるとencodingを変えられません。stdinは触らずバイト列で読み、stdoutだけ再設定する、という分担が安全です。stdoutはJSONだけにしておくのが確実です。デバッグ用のprintはstderrに出します。
PYTHONUTF8=1で全hookを一括で揃える
スクリプトを1本ずつ直す代わりに、Pythonを常にUTF-8モードで動かす手もあります。Pythonの仕様では、UTF-8モードはPYTHONUTF8=1または-X utf8で有効になり、sys.stdinとsys.stdoutがUTF-8を使うようになります。ロケールのエンコーディングは無視されます。
settings.jsonのenvは「すべてのセッションと、そこからClaude Codeが起動するサブプロセス」に環境変数を渡す設定です。hooksのリファレンスにも、ハンドラーはClaude Codeの環境で動くとあります。
{
"env": {
"PYTHONUTF8": "1"
}
}hookの定義側でフラグを渡す方法もあります。argsを持つ「実行形式」では、シェルを介さず実行ファイルが直接起動されます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python",
"args": ["-X", "utf8", "hooks/record.py"]
}
]
}
]
}
}両者の違いは範囲です。envは全セッションのサブプロセスに及ぶため、Claudeが実行するPythonのBashコマンドもUTF-8モードになります。-X utf8は指定したhookだけです。Python 3.15からはUTF-8モードが既定で有効になる(無効化はPYTHONUTF8=0)ので、この問題は新しいPythonでは縮みます。ただし、チームに3.14以前の環境が混ざる間は、コード側のバイト読みも残しておくと安全です。
エラーを出さずに失敗するhookの穴を塞ぐ
文字化けしたまま処理が進むと、見張りのhookは何も検知しません。ここで見るべき点が2つあります。
1つ目は、例外で落ちる型とそうでない型の違いです。コードページ上で未定義のバイトに当たるとUnicodeDecodeErrorで終了コード1になり、Claude Code hooksのonFailureで失敗時に操作を止める設定のblock指定で止められます。一方、例外なく化けるだけの型は、終了コード0のまま通るためonFailureの対象になりません。
2つ目は、ガードの設計です。cwdやfile_pathが想定外の文字列だったときは、通すのでなく止める側に寄せられます。たとえば入力のcwdが存在するディレクトリか確かめ、そうでなければ終了コード2で理由をstderrに出します。PreToolUseの決定の書き方はPreToolUse hookの解説にあります。
import json
import os
import sys
data = json.loads(sys.stdin.buffer.read().decode("utf-8-sig"))
cwd = data.get("cwd", "")
if not os.path.isdir(cwd):
print(f"cwdが存在しません: {cwd!r}", file=sys.stderr)
sys.exit(2)実際にWindowsで効いているかを確かめる
直したつもりが効いていない、を避けるには、hookが自分の環境をログに出すのが早道です。UTF-8モードの有無、stdinのエンコーディング、受け取ったバイト列の先頭を記録します。
# hooks/env_probe.py
import sys
raw = sys.stdin.buffer.read()
with open("hook-probe.log", "a", encoding="utf-8") as f:
f.write(f"utf8_mode={sys.flags.utf8_mode} ")
f.write(f"stdin={sys.stdin.encoding} ")
f.write(f"head={raw[:60]!r}\n")utf8_mode=1ならPYTHONUTF8か-X utf8が届いています。stdin=cp932かつutf8_mode=0なら、既定のコードページ読みのままです。headに\xe5\x90...のようなバイト列が見えれば、入力がUTF-8で届いている裏づけになります。日本語名のフォルダでセッションを開き、1回Writeを実行してからログを読んでください。
Claude Codeにhookを書かせるときの決まり
hook自体をClaudeに書かせることも多いはずです。生成物がjson.load(sys.stdin)に戻らないよう、CLAUDE.mdに規約として書いておけます。
## hookスクリプトの規約(Python)
- stdinは `sys.stdin.buffer.read().decode("utf-8-sig")` で読む。`json.load(sys.stdin)` は使わない
- stdoutのJSONは `ensure_ascii=True` のまま出すか、先に `sys.stdout.reconfigure(encoding="utf-8")` する
- 書き終えたら、日本語名のディレクトリを `cwd` にしたJSONで `PYTHONIOENCODING=cp932` を付けて実行し、出力を確認する最後の1行が肝です。「テストを実行して出力を読む」まで規約に含めると、Claudeが書いて終わりにせず、化けていないかを自分で確かめる反復になります。
範囲と限界
- この問題はPythonに限りません。issueの題材はPythonですが、「プラットフォーム既定のコードページでstdinを読む」実行環境は同じ型で壊れることがある、とissueの提案文にも書かれています
- 他のプラグイン実装にも同型の読み方が見つかっています。Hookifyの4つのエントリポイントが
json.load(sys.stdin)を使っているという検証報告もありますが、これはmacOS上でPYTHONIOENCODINGを変えた再現で、Windowsでの確認ではありません - 上流のPowerShellパイプでASCIIに潰された文字は、hook側では復元できません。
$OutputEncodingの設定を直すのが先です /copyで日本語が壊れる不具合とは原因が別です。あちらはクリップボードへの書き込み経路の問題で、Claude Codeの/copyで日本語が文字化けする原因と対処法で扱っています
hookをシェルのラッパー経由で動かしている場合は、ラッパー自体が文字コードを変えていないかも確認対象です。ラッパーの仕組みはCLAUDE_CODE_SHELL_PREFIXでBashやhookをラッパー経由にする方法にまとめています。
まとめ
日本語環境のWindowsでPythonのhookを動かすなら、入力はsys.stdin.bufferからUTF-8(BOM許容ならutf-8-sig)で読む、出力はensure_ascii=Trueのままにするかstdout.reconfigureを先に呼ぶ、の2点が土台です。そのうえでPYTHONUTF8=1をenvに入れておけば、書き漏れの穴が減ります。
公式のhooksリファレンスにはエンコーディングの記述がなく、MacやLinuxでの動作確認では見つかりません。日本語名のフォルダで1回動かし、ログでutf8_modeとstdinを確かめる手順を、hookを配るチームのチェックリストに入れておくと安全です。