Claude Media
Claude Codeが作るps1が文字化けして止まる原因とBOMの対処

Claude Codeが作るps1が文字化けして止まる原因とBOMの対処

WriteツールはBOMなしUTF-8で.ps1を保存します。Windows PowerShell 5.1はこれをANSIとして読み、全角ダッシュや日本語で文字列が壊れます。確認と対処をまとめます。

Claude Codeに書かせた.ps1を、Windows PowerShell 5.1で実行したらThe string is missing the terminatorと出て止まる。スクリプトを見ても、引用符は閉じているように見える。原因は構文ではなくエンコーディングで、Writeツールが.ps1をBOMなしのUTF-8で保存し、5.1がそれをANSIコードページとして読むことにあります。

anthropics/claude-codeのissue #90962に、この挙動の測定結果と再現手順が集まっています。この記事は、その内容をもとに壊れる条件、手元での確認方法、BOMを自動で付けるhookの組み方をまとめます。

なぜBOMなしの.ps1がWindows PowerShell 5.1で壊れるのか

Microsoftのドキュメントは、非ASCII文字を含むスクリプトはBOM付きUTF-8で保存するよう書いています。BOMがないと、Windows PowerShellはスクリプトを旧来の「ANSI」コードページのファイルとして誤解釈します。PowerShell 6以降の既定はBOMなしUTF-8なので、同じファイルでも問題になりません。

issueの報告者は、壊れ方を1バイト単位で説明しています。

em dash U+2014   は UTF-8 で E2 80 94
バイト 0x94 は CP1252 で U+201D(右二重引用符)

全角ダッシュ—の3バイト目が、ANSI(CP1252)では引用符の文字に化けます。PowerShellはスマートクォートも文字列の区切りとして受け入れます。化けた引用符で文字列が早く閉じ、本物の閉じ引用符が新しい文字列を開き、終端がない状態になります。エラーは見た目に問題のない行を指します。

エージェントが書くバナー行、たとえばWrite-Host "レポート — 夜間バッチ"は、まさにこの危険な位置に入ります。

どこに書いた非ASCIIが壊れるのか

報告者はWindows 11、powershell.exe 5.1.22621.6133、ANSIコードページ1252で、パターンごとにトークナイザーの結果を測っています。

BOMなしUTF-8の.ps1の中身結果
ダブルクォート文字列内の全角ダッシュ結果パースエラー1件(終端なし)
コード本体にある全角ダッシュ結果パースエラー1件
シングルクォート文字列内のキリル文字結果パースエラー1件
#コメント内の全角ダッシュ・キリル文字・矢印結果エラー0件、表示は文字化け
文字列内の絵文字(U+2705)結果エラー0件、表示は文字化け

危険なのは文字列の中とコード本体で、コメントは化けるだけです。ただし、コメントだけなら安全という話ではありません。どの文字が引用符に化けるかで結果が変わるためです。

日本語は1行目が動いて2行目で落ちることがある

日本語の確認は、別のコメント投稿者がWindows PowerShell 5.1(5.1.26100)、CLI 2.1.296で行っています。BOMなしの.ps1に次の1行だけを書くと、実行は成功し、出力が文字化けするだけでした。

Write-Output "こんにちは"

ここに2行目のWrite-Output "さようなら"を足すと、5.1は2行目でThe string is missing the terminator: "を出して止まります。同じファイルにBOMを戻すと、2行とも正しく出力されます。

1行だけのテストで通ると、安全と誤認します。壊れるかどうかは、ANSI読みで引用符になるバイトが現れるかどうかで決まります。

WriteはBOMを落とし、CRLFをLFにする

issueのコメントでは、もう一つの挙動が報告されています。手でBOMを付けて直した既存の.ps1を、Claude Codeが次に書き直したときです。

  1. BOMとCRLFを持つgreet.ps1をWriteで1行追加して書き戻す
  2. ファイルの先頭からEF BB BFが消え、改行がCRLFからLFになる
  3. 5.1で実行すると、日本語が文字化けしてTerminatorExpectedAtEndOfStringが出る

同じ種類のファイルをEditツールで変更すると、BOMもCRLFも保たれます。この差はmacOSでのバイト確認でも再現しており、Windows固有の経路ではありません。5.1だけが、BOMなしのファイルをANSIで読むため、差が実行時エラーとして表に出ます。

CLI 2.1.296でもWriteの挙動は同じだと確認されています。issue #90962は、取得時点でopenのままです。

整理すると、次の組み合わせでBOMが落ちます。

操作BOM改行
Writeで新規作成BOMなし改行LF
BOM付き・CRLFの既存ファイルをWriteで上書きBOM消える改行CRLFがLFに変わる
BOM付き・CRLFの既存ファイルをEditで変更BOM保たれる改行CRLFのまま

手元の.ps1にBOMがあるか確認する

先頭の3バイトを見れば足ります。EF BB BFならBOM付きです。PowerShell 5.1と7のどちらでも動く書き方を使います。

[IO.File]::ReadAllBytes(".\script.ps1")[0..2] | ForEach-Object { '{0:X2}' -f $_ }

EF BB BFと3行出れば、BOMがあります。それ以外ならBOMなしです。

フォルダー全体を調べるなら、非ASCIIを含み、かつBOMのないファイルを拾います。そのまま実行できるスクリプトの例です。

Get-ChildItem -Recurse -Include *.ps1,*.psm1 | ForEach-Object {
  $b = [IO.File]::ReadAllBytes($_.FullName)
  $bom = $b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF
  $nonAscii = ($b | Where-Object { $_ -gt 127 } | Select-Object -First 1) -ne $null
  if ($nonAscii -and -not $bom) { $_.FullName }
}

出てきたファイルは、いま壊れていなくても、文字を1つ足しただけで壊れる状態です。issueの報告者の環境でも、67個の.ps1のうち16個がこの状態でした。

対処1: 見つけたファイルにBOMを足す

その場しのぎなら、既存のバイト列の前にEF BB BFを付けて書き戻します。

$p = (Resolve-Path .\script.ps1).Path
$bytes = [IO.File]::ReadAllBytes($p)
[IO.File]::WriteAllBytes($p, [byte[]](0xEF,0xBB,0xBF) + $bytes)

エディターでUTF-8 with BOMとして保存し直しても、同じ結果になります。Microsoftのドキュメントによれば、VS Codeのようなクロスプラットフォームのエディターは既定でBOMなしのUTF-8を作ります。

対処2: PostToolUse hookでWriteのたびにBOMを付ける

Claude Codeに書かせるたびに手作業で直すのは続きません。issueのコメントでも、Writeに対するPostToolUse hookで.ps1の先頭3バイトを確認し、足りなければBOMを付ける運用が紹介されています。hooksの基本はClaude Codeのhooks解説にあります。

公式のhooksドキュメントには、powershell.exeに-NoProfile -ExecutionPolicy Bypass -Fileを渡してスクリプトを呼ぶ例があります。引数をリストで渡すargsを使うと、シェルを介さずに起動できます。次の例は、その形をPostToolUseのWriteに当てた.claude/settings.jsonの断片です。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "powershell.exe",
            "args": [
              "-NoProfile",
              "-ExecutionPolicy",
              "Bypass",
              "-File",
              "${CLAUDE_PROJECT_DIR}/.claude/hooks/add-ps1-bom.ps1"
            ]
          }
        ]
      }
    ]
  }
}

hookにはstdinでJSONが渡され、Writeならtool_input.file_pathにパスが入ります。スクリプトはこのパスを見て、.ps1と.psm1だけにBOMを足します。スクリプト自体はASCIIだけで書きます。BOMなしで保存されても壊れないようにするためです。

# .claude/hooks/add-ps1-bom.ps1  (ASCII only)
$in = [Console]::In.ReadToEnd() | ConvertFrom-Json
$path = $in.tool_input.file_path
 
if ($path -and $path -match '\.(ps1|psm1)$' -and (Test-Path -LiteralPath $path)) {
  $bytes = [IO.File]::ReadAllBytes($path)
  $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF
  if (-not $hasBom) {
    [IO.File]::WriteAllBytes($path, [byte[]]([byte[]](0xEF,0xBB,0xBF) + $bytes))
  }
}
exit 0

この例は公式ドキュメントのhook設定と、issueの報告内容から組んだもので、Windows実機での実行は確かめていません。日本語を含むパスがstdinで正しく渡るかは検証外です。自分の環境で、日本語を含む.ps1を1つ書かせて先頭3バイトを確認してから使ってください。

Editは既存のBOMを保つので、matcherはWriteだけで足ります。Edit側にも掛けたければEdit|Writeですが、スクリプトは冪等なので害はありません。

hookをPowerShellで書くときのEncodingの罠

issueのコメントに、hookを自作するときの落とし穴が出ています。Set-Content -Encoding UTF8は、5.1ではEF BB BF付きで書きます。PowerShell 7以降では-Encoding UTF8がBOMなしを意味し、BOM付きはutf8BOMです。Microsoftのドキュメントも、Windows PowerShellでUTF-8を含むUnicodeエンコーディングが常にBOMを作ることと、v6以降の既定がutf8NoBOMであることを記しています。

7で書いて試したhookが-Encoding UTF8を使うと、BOMなしで書き出します。5.1が必要な機械では、それがまさに壊れる形のファイルです。上のスクリプトがバイト列を直接書き換えるのは、この差を避けるためです。

対処3: BOMに頼らず、ASCIIに寄せる

BOMを付ける代わりに、.ps1の中身をASCIIだけにする方法もあります。issueの提案も「BOMを出力する、またはASCIIに限る」の二択です。CLAUDE.mdに次のような規約を書いておくと、Claude Codeは文字列とコード本体を英語で書きます。

## PowerShellスクリプト
- .ps1 / .psm1 の文字列・コード・コメントには非ASCII文字(全角ダッシュ、日本語、絵文字)を書かない
- 出力メッセージは英語にする。日本語の説明は別の README に書く

メッセージを日本語で出したいスクリプトには向きません。その場合は対処2のhookを併用します。

もう一つの選択肢は、実行側をPowerShell 7に変えることです。PowerShell 7はBOMなしのUTF-8をそのままUTF-8として読むので、この問題は起きません。ただし、配布先の機械にpwshが入っている保証がなければ、5.1でも動くファイルにしておくのが安全です。Claude Codeがどのシェルを使うかはシェル起動設定とPowerShellを直接起動する環境変数で決まりますが、Claudeが生成したファイルを後で誰が実行するかは別の話です。

タスクスケジューラ経由だと、失敗が表に出ない

手で実行すれば、エラーは画面に出ます。issueの報告者が重く見ているのは、スケジューラ経由の場合です。

  • タスクの結果は0x80070001(「不正な関数」)になる
  • スクリプトが一行も実行されないので、ログもトランスクリプトも残らない
  • タスクの履歴上は、アクションが完了した扱いになる

報告者は、修復用のスクリプト2本を4日間この状態で放置していました。ただし、その2本はWriteツールが作ったものではないと断っています。失敗の代償を示す例であって、ツールが原因だった証拠ではありません。

見分ける手がかりは、スクリプトが自分で出すはずのログファイルが「空」ではなく「存在しない」ことです。コメント投稿者は、スクリプトの最初の実行行で開始時刻入りのファイルを作り、翌朝そのファイルが新しいか別の仕組みで確かめる方法を挙げています。BOMの問題でも、パスワード切れでも、パスの移動でも、同じ形で気づけます。

BOMを付けてはいけないファイルもある

BOMはすべてのファイルに付ければよいわけではありません。issueは、読む側の問題として別のissue(#73158)に触れています。.mdやエージェント定義、スキル定義の先頭にBOMがあると、定義ファイルが見えなくなると報告されています。issueの提案も、.ps1などの書き出し時だけBOMを付け、.md・.jsonは読み込み時にBOMを取り除くと、拡張子ごとに分けています。

hookのmatcherをWriteにしても、スクリプト側で.ps1と.psm1にだけ作用させているのはこのためです。

まとめ

Windows PowerShell 5.1で動かす.ps1をClaude Codeに書かせるなら、BOMの有無を先頭3バイトで確認できる状態にしておくと、原因の切り分けが一瞬で済みます。

  • 壊れるのは、BOMなしのUTF-8を5.1がANSIとして読むため。文字列内とコード本体の非ASCIIが危ない
  • Writeは新規でも上書きでもBOMを付けず、既存ファイルのCRLFもLFに変える。Editは両方を保つ
  • 日本語は1行目が動いて2行目で落ちることがあり、1行のテストは安全の証拠にならない
  • 対処は、BOMを後付けするPostToolUse hook、ASCIIへ寄せるCLAUDE.mdの規約、実行側をPowerShell 7にする選択肢

スケジューラで動かすスクリプトは、実行前に先頭3バイトを見るだけで、4日間の空振りを防げます。同じ「文字化け」でも、クリップボード側の現象は/copyの文字化けで扱っています。

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