はじめに

あるプロジェクトにて、500ファイルほどのASPアプリケーションの.NET FW導入&AWS移行で「Claude Code」を活用することになりました。ただ、一つ大きな問題がありました。

ファイルが全部Shift_JISだったのです。

本プロジェクトでは、「現行構成を維持し最小限の変更で移行」という方針のため、UTF-8に変更するリスクや工数を取らない、という判断でShift_JISを維持する必要がありました。一方で、Claude CodeのRead/Edit/Writeツールは全てUTF-8前提で動作します。Shift_JISファイルを直接Readすると文字化け、Editでは日本語を含む検索文字列がマッチしない。つまり、そのままではClaude Codeがまともにコードを読み書きできないという状況でした。

本記事では、Claude Code Hooksとgit pre-commit hookを組み合わせて、この問題を解決する自動変換の仕組みを共有したいと思います。

「毎回iconv」の限界

最初に試したのは、素朴なアプローチです。Claude CodeにReadさせる前に手動で iconv -f SHIFT_JIS -t UTF-8 を実行し、Edit後に iconv -f UTF-8 -t SHIFT_JIS で戻す。

これは3ファイルくらいなら成立します。しかし、数百ファイルを対象にした移行作業ではすぐに破綻しました。変換忘れでShift_JISファイルがUTF-8のままコミットされ、本番環境で文字化けが発生する。

「人間が忘れる作業は自動化すべき」——そこでClaude Code Hooksの出番です。

Claude Code Hooksとは

Claude Code Hooksは、Claude Codeのツール実行ライフサイクルにおける特定のタイミングで、ユーザー定義のシェルコマンドやスクリプトを自動実行できる仕組みです。.claude/settings.jsonに設定を記述します。

今回使うのは以下の2つのイベントです。

  • PreToolUse: ツール実行の直前に発火する。標準入力からツールのパラメータ(ファイルパスなど)をJSON形式で受け取れる
  • PostToolUse: ツール実行の直後に発火する。同様にJSON形式で処理結果を受け取れる

matcherにはツール名の正規表現を指定でき、例えば "Read|Edit" と書けばReadツールとEditツールの実行時にだけフックが走ります。

3層構造の自動変換パイプライン

理想的には、全ツールでPreToolUseとPostToolUseの両方が発火し、Claude Code Hooksだけでエンコーディングの往復変換が完結するはずでした。しかし実際に運用してみると、EditのPreToolUse Hooksが期待通り発火しないバグ(?)が多々起きてしまいました。(2026/03時点)

そこで、Claude Code Hooksだけに頼る設計を諦め、git pre-commit hookを安全策として追加する構成にしました。Hooksで変換しきれなかったファイルがあっても、コミット時にpre-commit hookが確実にShift_JISに戻します。

この構成にしたことで、Claude Code Hooksの挙動が将来的に改善されても壊れない設計になっています。Hooksが完全に動作すればpre-commit hookは何もせず、Hooksに問題があればpre-commit hookがカバーするといった形になります。

現状の各ツールの動作は以下のとおりです。

ツール PreToolUse PostToolUse 最終状態
Read SJIS→UTF8 UTF-8のまま(git commitで戻る)
Edit SJIS→UTF8 UTF8→SJIS Shift_JIS
Write UTF8→SJIS Shift_JIS

ReadのPostToolUseをあえて外しているのは、EditのPreToolUseが発火しなかった場合への備えです。ReadのPostToolUseでShift_JISに戻してしまうと、直後のEditでPreToolUseが発火しなかった場合にShift_JISファイルをUTF-8として編集しようとして失敗します。UTF-8のまま残しておけば、EditのPreToolUseが発火しなくても問題なく編集できます。

設定ファイルとHooksの要点

settings.jsonの設定

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-sjis-to-utf8.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-utf8-to-sjis.sh"
          }
        ]
      }
    ]
  }
}

$CLAUDE_PROJECT_DIR環境変数を使うことで、Claude Codeの作業ディレクトリがどこであっても正しくスクリプトが参照されます。

フックスクリプト(PreToolUse)

PreToolUseフック(SJIS→UTF8)の実装です。PostToolUseフックは逆方向の変換ですが、同じパターンで実装しています。

#!/bin/bash
# PreToolUse フック: Shift_JIS → UTF-8 変換

set -e

# 標準入力から JSON を読み取る
INPUT=$(cat)

# jq がない場合は何もしない
if ! command -v jq &> /dev/null; then
    exit 0
fi

# file_path を取得
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [ -z "$FILE_PATH" ]; then
    exit 0
fi

# 対象ディレクトリのフィルタリング
if [[ ! "$FILE_PATH" =~ src/ ]]; then
    exit 0
fi

# 対象拡張子のフィルタリング
if [[ ! "$FILE_PATH" =~ \.(aspx|asp|asax|html|css|inc)$ ]]; then
    exit 0
fi

if [ ! -f "$FILE_PATH" ]; then
    exit 0
fi

# エンコーディングを判定
ENCODING=$(file -b --mime-encoding "$FILE_PATH")

# UTF-8でもASCIIでもなければShift_JISとみなして変換
if [ "$ENCODING" != "utf-8" ] && [ "$ENCODING" != "us-ascii" ]; then
    echo "[PreToolUse] Converting $FILE_PATH from Shift_JIS to UTF-8..." >&2

    # タイムスタンプを保存
    ORIGINAL_TIMESTAMP=$(stat -c %y "$FILE_PATH" 2>/dev/null)

    TEMP_FILE="${FILE_PATH}.utf8.tmp"
    if iconv -f SHIFT_JIS -t UTF-8 "$FILE_PATH" > "$TEMP_FILE" 2>/dev/null; then
        mv "$TEMP_FILE" "$FILE_PATH"

        # タイムスタンプを復元
        if [ -n "$ORIGINAL_TIMESTAMP" ]; then
            touch -d "$ORIGINAL_TIMESTAMP" "$FILE_PATH" 2>/dev/null || true
        fi

        echo "[PreToolUse] ✓ Converted $FILE_PATH to UTF-8" >&2
    else
        rm -f "$TEMP_FILE"
        echo "[PreToolUse] ✗ Failed to convert $FILE_PATH" >&2
        exit 0  # エラーでもブロックしない
    fi
fi

exit 0

実際の動作時には、以下のようなログが出力されます。

[PreToolUse] Converting src/pages/login.asp from Shift_JIS to UTF-8...
[PreToolUse] ✓ Converted src/pages/login.asp to UTF-8
[PostToolUse] Converting src/pages/login.asp from UTF-8 to Shift_JIS...
[PostToolUse] ✓ Converted src/pages/login.asp to Shift_JIS

ハマりポイント

fileコマンドがShift_JISを正確に検出できない

file -b --mime-encodingコマンドは、Shift_JISのファイルをunknown-8bitiso-8859-1として報告することがあります。Shift_JISはASCIIの拡張であり、バイトパターンだけでは他のエンコーディングと区別が困難だからです。

そこで「UTF-8でもASCIIでもなければShift_JISとみなす」という形を採用しました。※Shift_JISとUTF-8以外が存在しないことを確認した上で

if [ "$ENCODING" != "utf-8" ] && [ "$ENCODING" != "us-ascii" ]; then
    # Shift_JISとみなして変換
fi

iconvでタイムスタンプが変わる

iconvは直接ファイルを上書きできないため、一時ファイルに出力してからmvで置き換える必要があります。このmvによってファイルのタイムスタンプが更新され、正確な更新時間とならなかったり、IDEなどでファイルの変更監視している場合は不要な処理を走らせてしまうことになります。変換前にstatでタイムスタンプを保存し、変換後にtouch -dで復元することで回避しています。

ORIGINAL_TIMESTAMP=$(stat -c %y "$FILE_PATH" 2>/dev/null)
iconv -f SHIFT_JIS -t UTF-8 "$FILE_PATH" > "${FILE_PATH}.tmp" && mv "${FILE_PATH}.tmp" "$FILE_PATH"
touch -d "$ORIGINAL_TIMESTAMP" "$FILE_PATH" 2>/dev/null || true

導入効果

このパイプラインの導入後、文字エンコーディング関連の問題は全て自動解決されました。開発者がエンコーディングを意識する場面はほぼなくなり、Claude Codeは何事もなかったかのようにShift_JISのコードベースで作業を続けています。

毎回iconvを手動実行していた頃と比べて、移行作業のスピードは体感で数倍になっています。

おわりに

レガシーシステムの移行において、AIツールの制約とレガシーコードの制約の間には、しばしばギャップがあります。今回のShift_JIS問題はその典型例でした。

Claude Code Hooksは、そのギャップを埋めるための有力な手段となります。エンコーディング変換に限らず、「ツール実行前後に任意のスクリプトを挟む」という仕組みは、ファイル書き込み後の自動フォーマットや特定ディレクトリへの書き込みブロックなど、様々な用途に応用できます。都度プロンプトで依頼するより、手間も減り、確実性も上がります。

同じような問題に悩んでいる方の参考になれば幸いです。