masalibの日記

システム開発、運用と猫の写真ブログです

Claude Code Hooks で$CLAUDE_FILE_PATHSが取得できない場合の対応について

2025/08/10 の時点の自分用のメモです。

Windows環境だからなのかハマった。その時のメモを残します

Claude Code Hooks とは

Claude Code Hooks は、Claude Code の動作を決定論的に制御する強力なシステムです。特定の条件下でのみコマンドを実行する。 ログを残したり、フォーマッターを実行したり、自動でテストしたり、終了通知をしたりできます。

環境について

  • 自分がおこなっているのは Windows 環境で WSL の環境です。
  • powershell でも Claude Code は起動できる

インストールしたもの

  • Node.js
  • git2.3+
  • JQ
  • uvx

Hooks を入れたいと思ったこと

私が触っっているプログラムの文字コードが shift-jis にだった。 Claude Code は utf-8 で動いているので文字コードの問題に直面しました。 Claude Code に指示をしても文字化けが解消されることはなかった。

下記を対応することで文字化けを対応しようと思いました。

  1. 事前にプログラムを shift-jis から utf-8 にする
  2. Claude Code でプログラムで変更する
  3. 変更されたファイルを utf-8 から shift-jis に変換する

hooksの設定テストについて

hooks の設定ファイルは変更したあとに再読込しないといけなかったので コマンドで修正する形で対応した

claude --dangerously-skip-permissions --debug -p "update hooktest.XXX 上部のコメントをtest1に変更する"

--dangerously-skip-permissions はテストなので何も OK にした --debug はログ出力しないとまったくわからなかったため追加した -p が claude code の画面を出さずに内容を実行してくれるモード

試した設定

.claude\settings.local.json を作成(自分の場合はもともとあったので変更)した

{
  "permissions": {
    "allow": ["Bash(cp:*)", "Bash(rm:*)", "Bash(sed:*)"],
    "deny": []
  },
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 convert_encoding_utf8_to_shiftjis.py \"$CLAUDE_FILE_PATHS\""
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 convert_encoding_shiftjis_to_utf8.py \"$CLAUDE_FILE_PATHS\""
          }
        ]
      }
    ]
  }
}

変更される予定のファイルと変更されたファイルを渡すことで文字コードを変換するようにしたかった

これがうまくいかなかった CLAUDE_FILE_PATHS という環境変数が何をしても取得できなかった。

参考にしたサイトだと以下でCLAUDE_FILE_PATHSが出力された。

{
  "hooks": {
    "PostToolUse": [
      {
        "name": "File Change Notification",
        "condition": {
          "tool_name": "edit_file"
        },
        "hooks": [
          {
            "type": "command",
            "command": "echo '📝 File modified: $CLAUDE_FILE_PATHS'"
          }
        ]
      }
    ]
  }
}

https://smartscope.blog/AI%E9%96%8B%E7%99%BA%E3%83%BB%E8%87%AA%E5%8B%95%E5%8C%96/claude-code-hooks-hands-on-implementation/ より引用

どうしても出力されなかった。

本家の Claude の AI の聞いたら Windows 環境だと違うじゃないの? 出力された内容は以下だった

Windows特有の対応方法(失敗した内容)

失敗した内容なのでみたい人だけどうぞ

ここをクリックすると失敗の履歴

1. PowerShell を使用した方法

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "powershell -Command \"$input = [Console]::In.ReadToEnd(); Write-Host $input\""
          }
        ]
      }
    ]
  }
}

2. 外部スクリプトファイルを使用(推奨)

hook_handler.ps1 ファイルを作成:

# hook_handler.ps1
$input = [Console]::In.ReadToEnd()
$jsonData = $input | ConvertFrom-Json

# JSONデータを整形して出力
Write-Host "=== Hook Input JSON ==="
Write-Host ($jsonData | ConvertTo-Json -Depth 10)

# 特定のフィールドにアクセス
if ($jsonData.tool_input -and $jsonData.tool_input.file_path) {
    Write-Host "File Path: $($jsonData.tool_input.file_path)"
}

# 環境変数も確認
Write-Host "=== Environment Variables ==="
Get-ChildItem env: | Where-Object Name -like '*CLAUDE*' | ForEach-Object {
    Write-Host "$($_.Name): $($_.Value)"
}

フック設定:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "powershell -ExecutionPolicy Bypass -File hook_handler.ps1"
          }
        ]
      }
    ]
  }
}

3. Python スクリプトを使用

hook_handler.py ファイルを作成:

import sys
import json

def main():
    try:
        # stdinからJSONデータを読み取り
        input_data = sys.stdin.read()

        print("=== Raw Input ===")
        print(input_data)

        if input_data.strip():
            # JSONとして解析
            json_data = json.loads(input_data)

            print("\n=== Parsed JSON ===")
            print(json.dumps(json_data, indent=2, ensure_ascii=False))

            # 特定のフィールドにアクセス
            if 'tool_input' in json_data:
                print(f"\nTool Input: {json_data['tool_input']}")

            if 'session' in json_data:
                print(f"\nSession Info: {json_data['session']}")

        else:
            print("No input received")

    except json.JSONDecodeError as e:
        print(f"JSON decode error: {e}")
    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    main()

フック設定:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python hook_handler.py"
          }
        ]
      }
    ]
  }
}

4. バッチファイルを使用

hook_handler.bat ファイルを作成:

@echo off
echo === Hook Input ===
set /p input=
echo %input%
echo === End Input ===

フック設定:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "hook_handler.bat"
          }
        ]
      }
    ]
  }
}

どれも動かず・・・途方にくれていました

stdinの内容をJSONを出力させる

公式のドキュメントに

フックは、セッション情報とイベント固有のデータを含む JSON データを stdin 経由で受信します

と記載があったのでそちらをもとに 検索したら以下のページがヒットした

dev.classmethod.jp

{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Task|TodoWrite|Write|MultiEdit|Edit",
      "hooks": [{
        "type": "command",
        "command": "jq '.' >> ~/.claude/post_tool_use_hook_debug.log"
      }]
    }]
  }
}

上記の設定では、Claude CodeがTaskやWrite等のToolを使用した後にjqコマンドが実行され、コマンドの結果が~/.claude/post_tool_use_hook_debug.log に出力されます。jqコマンドは標準入力として入力されたJSON文字列を解析(パース)し、整形して出力します。jq '.' の 「.」 は、「入力されたJSONをそのまま出力する」という意味のフィルタになります。

実際に実行したら自分の環境でも成功した 微妙に違うがほぼこの内容だった 実際に出力されたログ

{
  "session_id": "6da5a0c0-1d49-44ce-b3b4-9c0c627c33ad",
  "transcript_path": "/home/ubuntu/.claude/projects/-mnt-d---------svn------------------/6da5a0c0-1d49-44ce-b3b4-9c0c627c33ad.jsonl",
  "cwd": "/mnt/d/作業用フォルダ/svn/コンテンツ/作業用/テスト",
  "hook_event_name": "PreToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/mnt/d/作業用フォルダ/svn/コンテンツ/作業用/テスト/hooktest.asp",
    "old_string": "<%\nOption Explicit",
    "new_string": "<%\n' test19\nOption Explicit"
  }
}

環境変数にいれたいものが tool_input.file_path に入ることがわかりました

最終形の設定

{
  "permissions": {
    "allow": ["Bash(cp:*)", "Bash(rm:*)", "Bash(sed:*)"],
    "deny": []
  },
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq '.' > .claude/hooks/post_tool_json_data.log && python3 .claude/hooks/convert_encoding_utf8_to_shiftjis.py .claude/hooks/post_tool_json_data.log"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq '.' > .claude/hooks/pre_tool_json_data.log && python3 .claude/hooks/convert_encoding_shiftjis_to_utf8.py .claude/hooks/pre_tool_json_data.log"
          }
        ]
      }
    ]
  }
}

いらないかもしれないけどJSONを解析してファイルパスを取得して その後文字コード変換するプログラムです

・convert_encoding_shiftjis_to_utf8.py

みたい人はここをクリックすると見れます

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
UTF-8変換ツール(バックアップなし直接保存版)
Shift-JISからUTF-8への変換
"""

import sys
import os
import codecs
import chardet
import tempfile
import shutil
import errno
import json

class Config:
    MAX_FILE_SIZE = 100 * 1024 * 1024
    CHUNK_SIZE = 65536
    DETECTION_SAMPLE_SIZE = 65536
    ENCODING_SHIFT_JIS = 'SHIFT_JIS'
    ENCODING_UTF8 = 'UTF-8'
    ENCODING_UTF8_SIG_BOM = 'UTF-8-SIG(BOMあり)'
    DEFAULT_READ_ENCODING_FOR_ASCII = 'utf-8'

def is_binary_file(filepath):
    try:
        with open(filepath, 'rb') as f:
            chunk = f.read(1024)
            if not chunk:
                return False

            if chunk.startswith(b'\xff\xfe') or chunk.startswith(b'\xfe\xff'):
                return False

            return b'\x00' in chunk
    except Exception:
        return True

def has_bom_utf8(filepath):
    try:
        with open(filepath, 'rb') as f:
            bom = f.read(3)
            return bom == b'\xef\xbb\xbf'
    except Exception:
        return False

def normalize_encoding_name(encoding):
    if not encoding:
        return encoding

    encoding_lower = encoding.lower()

    mapping = {
        'utf-8': Config.ENCODING_UTF8, 'utf8': Config.ENCODING_UTF8, 
        'utf-8-sig': Config.ENCODING_UTF8_SIG_BOM, 'utf8-sig': Config.ENCODING_UTF8_SIG_BOM,
        'utf-16le': 'UTF-16LE', 'utf16le': 'UTF-16LE',
        'utf-16be': 'UTF-16BE', 'utf16be': 'UTF-16BE',
        'euc-jp': 'EUC-JP', 'eucjp': 'EUC-JP',
        'iso-2022-jp': 'ISO-2022-JP', 'iso2022jp': 'ISO-2022-JP',
        'shift_jis': Config.ENCODING_SHIFT_JIS, 'shift-jis': Config.ENCODING_SHIFT_JIS, 
        'sjis': Config.ENCODING_SHIFT_JIS, 'cp932': Config.ENCODING_SHIFT_JIS, 
        'windows-31j': Config.ENCODING_SHIFT_JIS,
        'windows-1252': 'WINDOWS-1252',
        'iso-8859-1': 'ISO-8859-1',
    }

    if encoding_lower in mapping:
        return mapping[encoding_lower]

    return encoding.upper()

def test_utf8_decode(raw_data):
    try:
        raw_data.decode('utf-8')
        return True
    except UnicodeDecodeError:
        return False

def detect_encoding(filepath):
    try:
        file_size = os.path.getsize(filepath)
        if file_size > Config.MAX_FILE_SIZE:
            return None, f"ファイルサイズが大きすぎます({file_size // (1024*1024)}MB)"

        if file_size == 0:
            return None, "空ファイルです"

        sample_size = min(file_size, Config.DETECTION_SAMPLE_SIZE)
        with open(filepath, 'rb') as f:
            raw_data = f.read(sample_size)

        if not raw_data:
            return None, "空ファイルです"

        if raw_data.startswith(b'\xff\xfe'):
            return 'UTF-16LE', None
        elif raw_data.startswith(b'\xfe\xff'):
            return 'UTF-16BE', None

        has_utf8_bom = raw_data.startswith(b'\xef\xbb\xbf')
        if has_utf8_bom:
            if test_utf8_decode(raw_data[3:]):
                return Config.ENCODING_UTF8_SIG_BOM, None
            else:
                return None, "UTF-8 BOMがありますが、内容が不正です"

        if is_binary_file(filepath):
            return None, "バイナリファイルです"

        data_for_chardet = raw_data[3:] if has_utf8_bom else raw_data
        result = chardet.detect(data_for_chardet)

        if result is None or result.get('encoding') is None:
            return None, "文字コードを検出できませんでした"

        detected_chardet_encoding = result['encoding']
        confidence = result.get('confidence', 0)

        normalized_encoding = normalize_encoding_name(detected_chardet_encoding)

        if normalized_encoding in [Config.ENCODING_UTF8, 'ASCII']:
            if test_utf8_decode(raw_data):
                return Config.ENCODING_UTF8, None

        if confidence > 0.8:
            if normalized_encoding == Config.ENCODING_SHIFT_JIS and test_utf8_decode(raw_data):
                return Config.ENCODING_UTF8, None
            return normalized_encoding, None

        return None, f"文字コードの検出信頼性が低いです ({detected_chardet_encoding}, confidence={confidence:.2f})"

    except PermissionError:
        return None, "ファイルにアクセスできません"
    except Exception as e:
        return None, f"エンコーディング検出エラー: {str(e)}"

def get_read_encoding(encoding):
    if encoding == Config.ENCODING_UTF8_SIG_BOM:
        return 'utf-8-sig'
    elif encoding == Config.ENCODING_UTF8:
        return 'utf-8'
    elif encoding == Config.ENCODING_SHIFT_JIS:
        return 'cp932'
    elif encoding == 'EUC-JP':
        return 'euc-jp'
    elif encoding == 'ISO-2022-JP':
        return 'iso-2022-jp'
    elif encoding == 'WINDOWS-1252':
        return 'windows-1252'
    elif encoding == 'ISO-8859-1':
        return 'iso-8859-1'
    elif encoding == 'ASCII':
        return Config.DEFAULT_READ_ENCODING_FOR_ASCII
    else:
        return encoding.lower().replace('-', '_')

def create_temp_file_safely(output_dir):
    temp_fd = -1
    temp_filepath = None
    
    try:
        if not os.path.isdir(output_dir):
            try:
                os.makedirs(output_dir, exist_ok=True)
            except OSError as e:
                 raise Exception(f"一時ファイル用ディレクトリの作成に失敗: {output_dir}, {str(e)}")

        temp_fd, temp_filepath = tempfile.mkstemp(
            suffix='.tmp', 
            prefix='utf8_conv_', 
            dir=output_dir
        )
        os.close(temp_fd)
        temp_fd = -1
        return temp_filepath
        
    except Exception as e:
        if temp_fd != -1:
            try:
                os.close(temp_fd)
            except OSError:
                pass
        if temp_filepath and os.path.exists(temp_filepath):
            try:
                os.remove(temp_filepath)
            except OSError:
                pass
        raise Exception(f"一時ファイルの作成に失敗: {str(e)}")

def convert_file_stream(input_filepath, encoding):
    """ファイルを直接上書き変換(バックアップなし)"""
    read_encoding = get_read_encoding(encoding)
    temp_file = None

    try:
        output_dir = os.path.dirname(input_filepath)
        if not output_dir:
            output_dir = os.getcwd()

        # 一時ファイルを作成
        temp_file = create_temp_file_safely(output_dir)

        write_encoding = 'utf-8'

        # 一時ファイルにUTF-8で書き込み
        with codecs.open(input_filepath, 'r', encoding=read_encoding, errors='replace') as infile, \
             codecs.open(temp_file, 'w', encoding=write_encoding, errors='replace') as outfile:

            while True:
                chunk = infile.read(Config.CHUNK_SIZE)
                if not chunk:
                    break
                outfile.write(chunk)

        # 一時ファイルを元ファイルに上書き(バックアップなし)
        try:
            shutil.move(temp_file, input_filepath)
            temp_file = None  # 移動成功したのでNoneにしてfinallyでの削除を回避
        except OSError as e:
            if hasattr(e, 'errno') and e.errno == errno.EEXIST:
                raise Exception(f"Output file {input_filepath} already exists.")
            raise Exception(f"Failed to move temporary file: {str(e)}")
        
        return True, None

    except PermissionError:
        return False, 'ファイルの保存権限がありません'
    except OSError as e:
        if hasattr(e, 'winerror') and e.winerror == 112:
            return False, 'ディスク容量が不足しています'
        if e.errno == errno.ENOSPC:
            return False, 'ディスク容量が不足しています'
        return False, f'ファイルの保存/OSエラー: {str(e)}'
    except Exception as e:
        return False, f'ファイルの保存中に予期せぬエラー: {str(e)}'
    finally:
        # 一時ファイルが残っている場合は削除
        if temp_file and os.path.exists(temp_file):
            try:
                os.remove(temp_file)
            except OSError:
                pass

def convert_to_utf8(filepath):
    """メイン変換関数(直接上書き版)"""
    result = {
        'success': False,
        'message': '',
        'original_encoding': '',
        'converted': False,
        'skipped': False
    }

    try:
        if not os.path.exists(filepath):
            result['message'] = 'ファイルが見つかりません'
            return result

        abs_filepath = os.path.abspath(filepath)
        if not os.path.isfile(abs_filepath):
            result['message'] = '有効なファイルパスではありません'
            return result

        encoding, error_msg = detect_encoding(abs_filepath)

        if error_msg:
            if error_msg == "空ファイルです":
                result['success'] = True
                result['message'] = '空ファイルのためスキップ'
                result['skipped'] = True
                result['original_encoding'] = 'N/A'
                return result
            else:
                result['message'] = error_msg
                return result

        if encoding is None:
            result['message'] = '文字コードを検出できませんでした'
            return result

        result['original_encoding'] = encoding

        # UTF-8やUTF-8 BOMの場合はスキップ
        if encoding in [Config.ENCODING_UTF8, Config.ENCODING_UTF8_SIG_BOM]:
            result['success'] = True
            result['message'] = 'UTF-8のためスキップ'
            result['skipped'] = True
            return result

        # 直接上書き変換
        conversion_success, conversion_error = convert_file_stream(abs_filepath, encoding)

        if conversion_success:
            result['success'] = True
            result['message'] = f'{os.path.basename(abs_filepath)} を直接上書き変換'
            result['converted'] = True
        else:
            result['message'] = conversion_error if conversion_error else 'ファイル変換に失敗しました'

        return result

    except KeyboardInterrupt:
        result['message'] = 'ユーザーによりキャンセルされました'
        return result
    except Exception as e:
        result['message'] = f'予期しないエラー: {str(e)}'
        return result

def format_result_message(result, original_filename):
    if not result['success'] and not result['skipped']:
        return f"{original_filename} → 変換失敗 ({result['message']})"

    if result['skipped']:
        return f"{original_filename} → {result['message']}"

    base_message = f"{original_filename} ({result['original_encoding']}) → 直接上書き変換完了 ({Config.ENCODING_UTF8})"

    return base_message

def _display_help_message():
    """使用方法のメッセージを表示する."""
    print("=" * 30)
    print("UTF-8変換ツール(JSON入力版)")
    print("=" * 30)
    print("使用方法:")
    print("1. JSONファイルのtool_input.file_pathで指定されたファイルの文字コードをUTF-8に変換します。")
    print("   ⚠️  注意:バックアップは作成されません!")
    print("   対応文字コード: Shift-JIS、EUC-JP、ISO-2022-JPなど。")
    print("   対応ファイル: テキストベースのファイル (例: csv, txt, html, asp)。バイナリファイルは不可。")
    print(f"   制限: 最大ファイルサイズ {Config.MAX_FILE_SIZE // (1024*1024)}MB。")
    print("2. 使用方法: python3 program.py json_file.json")
    print("3. JSONファイル形式:")
    print("   {")
    print("     \"tool_input\": {")
    print("       \"file_path\": \"/path/to/target/file\"")
    print("     }")
    print("   }")
    print("4. UTF-8は多くの文字を表現できるため、文字化けは基本的に発生しません。")

def _process_files_from_args(filepaths):
    """コマンドライン引数で渡されたファイルを処理し、結果を返す."""
    results = []
    converted_count = 0
    skipped_count = 0
    failed_count = 0
    total_count = len(filepaths)

    print("変換処理を開始します...")
    print("⚠️  元ファイルを直接上書きします(バックアップなし)\n")

    for i, filepath_arg in enumerate(filepaths, 1):
        try:
            original_filename = os.path.basename(filepath_arg) if filepath_arg else f"ファイル{i}"
            print(f"処理中 ({i}/{total_count}): {original_filename}")

            result = convert_to_utf8(filepath_arg)
            results.append((original_filename, result))

            if result['success']:
                if result.get('skipped', False):
                    skipped_count += 1
                elif result.get('converted', False):
                    converted_count += 1
            else:
                if not result.get('skipped', False):
                    failed_count +=1

        except Exception as e:
            print(f"エラーが発生しました: {e}", file=sys.stderr)
            failed_count += 1

    return results, converted_count, skipped_count, failed_count, total_count

def _display_conversion_summary(results, converted_count, skipped_count, failed_count, total_count):
    """変換結果の要約と詳細を表示する."""
    print(f"\n{'='*50}")
    print("=== 変換結果 ===")

    for fname, res_item in results:
        print(format_result_message(res_item, fname))

    summary_parts = []
    if converted_count > 0:
        summary_parts.append(f"{converted_count}ファイル変換")
    if skipped_count > 0:
        summary_parts.append(f"{skipped_count}ファイルスキップ")
    if failed_count > 0:
        summary_parts.append(f"{failed_count}ファイル失敗")

    summary_str = "、".join(summary_parts) if summary_parts else "処理対象なし"

    print(f"\n処理完了({total_count}ファイル中 {summary_str})")
    print(f"{'='*50}")

def main():
    try:
        if len(sys.argv) > 1:
            json_filepath = sys.argv[1]
            
            # JSONファイルの存在確認
            if not os.path.exists(json_filepath):
                print(f"エラー: JSONファイルが見つかりません: {json_filepath}")
                return
            
            # JSONファイルを読み込み
            try:
                with open(json_filepath, 'r', encoding='utf-8') as f:
                    json_data = json.load(f)
                
                # tool_input.file_pathを取得
                if 'tool_input' in json_data and 'file_path' in json_data['tool_input']:
                    file_path = json_data['tool_input']['file_path']
                    filepaths = [file_path]  # 配列として設定
                    print(f"JSONから取得したファイルパス: {file_path}")
                else:
                    print("エラー: JSONファイルにtool_input.file_pathが見つかりません")
                    return
                    
            except json.JSONDecodeError as e:
                print(f"エラー: JSONファイルの解析に失敗しました: {e}")
                return
            except Exception as e:
                print(f"エラー: JSONファイルの読み込みに失敗しました: {e}")
                return
            
            results, converted_count, skipped_count, failed_count, total_count = _process_files_from_args(filepaths)
            _display_conversion_summary(results, converted_count, skipped_count, failed_count, total_count)
        else:
            _display_help_message()

    except KeyboardInterrupt:
        print("\nプログラムが中断されました。")
    except Exception as e:
        import traceback
        print(f"\n重大なエラーが発生しました: {str(e)}", file=sys.stderr)
        print(f"{traceback.format_exc()}", file=sys.stderr)

    except (EOFError, KeyboardInterrupt):
        pass

if __name__ == "__main__":
    main()

・convert_encoding_utf8_to_shiftjis.py

みたい人はここをクリックすると見れます

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Shift-JIS変換ツール(バックアップなし直接保存版)
"""

import sys
import os
import codecs
import chardet
import tempfile
import shutil
import errno
import json

class Config:
    MAX_FILE_SIZE = 100 * 1024 * 1024
    CHUNK_SIZE = 65536
    DETECTION_SAMPLE_SIZE = 65536
    COMPATIBILITY_CHECK_SIZE = 1024
    ENCODING_SHIFT_JIS = 'SHIFT_JIS'
    ENCODING_UTF8 = 'UTF-8'
    ENCODING_UTF8_SIG_BOM = 'UTF-8-SIG(BOMあり)'
    DEFAULT_READ_ENCODING_FOR_ASCII = 'utf-8'

def is_binary_file(filepath):
    try:
        with open(filepath, 'rb') as f:
            chunk = f.read(1024)
            if not chunk:
                return False

            if chunk.startswith(b'\xff\xfe') or chunk.startswith(b'\xfe\xff'):
                return False

            return b'\x00' in chunk
    except Exception:
        return True

def has_bom_utf8(filepath):
    try:
        with open(filepath, 'rb') as f:
            bom = f.read(3)
            return bom == b'\xef\xbb\xbf'
    except Exception:
        return False

def normalize_encoding_name(encoding):
    if not encoding:
        return encoding

    encoding_lower = encoding.lower()

    mapping = {
        'utf-8': Config.ENCODING_UTF8, 'utf8': Config.ENCODING_UTF8, 
        'utf-8-sig': Config.ENCODING_UTF8_SIG_BOM, 'utf8-sig': Config.ENCODING_UTF8_SIG_BOM,
        'utf-16le': 'UTF-16LE', 'utf16le': 'UTF-16LE',
        'utf-16be': 'UTF-16BE', 'utf16be': 'UTF-16BE',
        'euc-jp': 'EUC-JP', 'eucjp': 'EUC-JP',
        'iso-2022-jp': 'ISO-2022-JP', 'iso2022jp': 'ISO-2022-JP',
        'shift_jis': Config.ENCODING_SHIFT_JIS, 'shift-jis': Config.ENCODING_SHIFT_JIS, 
        'sjis': Config.ENCODING_SHIFT_JIS, 'cp932': Config.ENCODING_SHIFT_JIS, 
        'windows-31j': Config.ENCODING_SHIFT_JIS,
        'windows-1252': 'WINDOWS-1252',
        'iso-8859-1': 'ISO-8859-1',
    }

    if encoding_lower in mapping:
        return mapping[encoding_lower]

    return encoding.upper()

def test_utf8_decode(raw_data):
    try:
        raw_data.decode('utf-8')
        return True
    except UnicodeDecodeError:
        return False

def detect_encoding(filepath):
    try:
        file_size = os.path.getsize(filepath)
        if file_size > Config.MAX_FILE_SIZE:
            return None, f"ファイルサイズが大きすぎます({file_size // (1024*1024)}MB)"

        if file_size == 0:
            return None, "空ファイルです"

        sample_size = min(file_size, Config.DETECTION_SAMPLE_SIZE)
        with open(filepath, 'rb') as f:
            raw_data = f.read(sample_size)

        if not raw_data:
            return None, "空ファイルです"

        if raw_data.startswith(b'\xff\xfe'):
            return 'UTF-16LE', None
        elif raw_data.startswith(b'\xfe\xff'):
            return 'UTF-16BE', None

        has_utf8_bom = raw_data.startswith(b'\xef\xbb\xbf')
        if has_utf8_bom:
            if test_utf8_decode(raw_data[3:]):
                return Config.ENCODING_UTF8_SIG_BOM, None
            else:
                return None, "UTF-8 BOMがありますが、内容が不正です"

        if is_binary_file(filepath):
            return None, "バイナリファイルです"

        data_for_chardet = raw_data[3:] if has_utf8_bom else raw_data
        result = chardet.detect(data_for_chardet)

        if result is None or result.get('encoding') is None:
            return None, "文字コードを検出できませんでした"

        detected_chardet_encoding = result['encoding']
        confidence = result.get('confidence', 0)

        normalized_encoding = normalize_encoding_name(detected_chardet_encoding)

        if normalized_encoding in [Config.ENCODING_UTF8, 'ASCII']:
            if test_utf8_decode(raw_data):
                return Config.ENCODING_UTF8, None

        if confidence > 0.8:
            if normalized_encoding == Config.ENCODING_SHIFT_JIS and test_utf8_decode(raw_data):
                return Config.ENCODING_UTF8, None
            return normalized_encoding, None

        return None, f"文字コードの検出信頼性が低いです ({detected_chardet_encoding}, confidence={confidence:.2f})"

    except PermissionError:
        return None, "ファイルにアクセスできません"
    except Exception as e:
        return None, f"エンコーディング検出エラー: {str(e)}"

def get_read_encoding(encoding):
    if encoding == Config.ENCODING_UTF8_SIG_BOM:
        return 'utf-8-sig'
    elif encoding == Config.ENCODING_UTF8:
        return 'utf-8'
    elif encoding == Config.ENCODING_SHIFT_JIS:
        return 'cp932'
    elif encoding == 'EUC-JP':
        return 'euc-jp'
    elif encoding == 'ISO-2022-JP':
        return 'iso-2022-jp'
    elif encoding == 'WINDOWS-1252':
        return 'windows-1252'
    elif encoding == 'ISO-8859-1':
        return 'iso-8859-1'
    elif encoding == 'ASCII':
        return Config.DEFAULT_READ_ENCODING_FOR_ASCII
    else:
        return encoding.lower().replace('-', '_')

def check_char_sjis_compatibility(char):
    try:
        encoded = char.encode('shift_jis')
        decoded = encoded.decode('shift_jis')
        return char == decoded
    except (UnicodeEncodeError, UnicodeDecodeError):
        return False

def check_sjis_compatibility_stream(filepath, encoding):
    try:
        read_encoding = get_read_encoding(encoding)
        incompatible_found = False

        with codecs.open(filepath, 'r', encoding=read_encoding, errors='replace') as infile:
            while True:
                chunk = infile.read(Config.COMPATIBILITY_CHECK_SIZE)
                if not chunk:
                    break

                for char in chunk:
                    if not check_char_sjis_compatibility(char):
                        incompatible_found = True
                        break

                if incompatible_found:
                    break

        return incompatible_found

    except Exception:
        return True

def create_temp_file_safely(output_dir):
    temp_fd = -1
    temp_filepath = None
    
    try:
        if not os.path.isdir(output_dir):
            try:
                os.makedirs(output_dir, exist_ok=True)
            except OSError as e:
                 raise Exception(f"一時ファイル用ディレクトリの作成に失敗: {output_dir}, {str(e)}")

        temp_fd, temp_filepath = tempfile.mkstemp(
            suffix='.tmp', 
            prefix='sjis_conv_', 
            dir=output_dir
        )
        os.close(temp_fd)
        temp_fd = -1
        return temp_filepath
        
    except Exception as e:
        if temp_fd != -1:
            try:
                os.close(temp_fd)
            except OSError:
                pass
        if temp_filepath and os.path.exists(temp_filepath):
            try:
                os.remove(temp_filepath)
            except OSError:
                pass
        raise Exception(f"一時ファイルの作成に失敗: {str(e)}")

def convert_file_stream(input_filepath, encoding):
    """ファイルを直接上書き変換(バックアップなし)"""
    read_encoding = get_read_encoding(encoding)
    temp_file = None

    try:
        output_dir = os.path.dirname(input_filepath)
        if not output_dir:
            output_dir = os.getcwd()

        # 一時ファイルを作成
        temp_file = create_temp_file_safely(output_dir)

        write_encoding = 'shift_jis'

        # 一時ファイルにShift-JISで書き込み
        with codecs.open(input_filepath, 'r', encoding=read_encoding, errors='replace') as infile, \
             codecs.open(temp_file, 'w', encoding=write_encoding, errors='replace') as outfile:

            while True:
                chunk = infile.read(Config.CHUNK_SIZE)
                if not chunk:
                    break
                outfile.write(chunk)

        # 一時ファイルを元ファイルに上書き(バックアップなし)
        try:
            shutil.move(temp_file, input_filepath)
            temp_file = None  # 移動成功したのでNoneにしてfinallyでの削除を回避
        except OSError as e:
            if hasattr(e, 'errno') and e.errno == errno.EEXIST:
                raise Exception(f"Output file {input_filepath} already exists.")
            raise Exception(f"Failed to move temporary file: {str(e)}")
        
        return True, None

    except PermissionError:
        return False, 'ファイルの保存権限がありません'
    except OSError as e:
        if hasattr(e, 'winerror') and e.winerror == 112:
            return False, 'ディスク容量が不足しています'
        if e.errno == errno.ENOSPC:
            return False, 'ディスク容量が不足しています'
        return False, f'ファイルの保存/OSエラー: {str(e)}'
    except Exception as e:
        return False, f'ファイルの保存中に予期せぬエラー: {str(e)}'
    finally:
        # 一時ファイルが残っている場合は削除
        if temp_file and os.path.exists(temp_file):
            try:
                os.remove(temp_file)
            except OSError:
                pass

def convert_to_sjis(filepath):
    """メイン変換関数(直接上書き版)"""
    result = {
        'success': False,
        'message': '',
        'original_encoding': '',
        'has_incompatible_chars': False,
        'converted': False,
        'skipped': False
    }

    try:
        if not os.path.exists(filepath):
            result['message'] = f'ファイルが見つかりません: {filepath}'
            return result

        abs_filepath = os.path.abspath(filepath)
        if not os.path.isfile(abs_filepath):
            result['message'] = '有効なファイルパスではありません'
            return result

        encoding, error_msg = detect_encoding(abs_filepath)

        if error_msg:
            if error_msg == "空ファイルです":
                result['success'] = True
                result['message'] = '空ファイルのためスキップ'
                result['skipped'] = True
                result['original_encoding'] = 'N/A'
                return result
            else:
                result['message'] = error_msg
                return result

        if encoding is None:
            result['message'] = '文字コードを検出できませんでした'
            return result

        result['original_encoding'] = encoding

        if encoding == Config.ENCODING_SHIFT_JIS:
            result['success'] = True
            result['message'] = 'SHIFT_JISのためスキップ'
            result['skipped'] = True
            return result

        result['has_incompatible_chars'] = check_sjis_compatibility_stream(abs_filepath, encoding)

        # 直接上書き変換
        conversion_success, conversion_error = convert_file_stream(abs_filepath, encoding)

        if conversion_success:
            result['success'] = True
            result['message'] = f'{os.path.basename(abs_filepath)} を直接上書き変換'
            result['converted'] = True
        else:
            result['message'] = conversion_error if conversion_error else 'ファイル変換に失敗しました'

        return result

    except KeyboardInterrupt:
        result['message'] = 'ユーザーによりキャンセルされました'
        return result
    except Exception as e:
        result['message'] = f'予期しないエラー: {str(e)}'
        return result

def format_result_message(result, original_filename):
    if not result['success'] and not result['skipped']:
        return f"{original_filename} → 変換失敗 ({result['message']})"

    if result['skipped']:
        return f"{original_filename} → {result['message']}"

    base_message = f"{original_filename} ({result['original_encoding']}) → 直接上書き変換完了 ({Config.ENCODING_SHIFT_JIS})"

    if result['has_incompatible_chars']:
        base_message += "、代替文字に置換あり"

    return base_message

def _display_help_message():
    """使用方法のメッセージを表示する."""
    print("=" * 30)
    print("Shift_JIS変換ツール(JSON入力版)")
    print("=" * 30)
    print("使用方法:")
    print("1. JSONファイルのtool_input.file_pathで指定されたファイルの文字コードをShift_JISに変換します。")
    print("   ⚠️  注意:バックアップは作成されません!")
    print("   対応文字コード: UTF-8、EUC-JP、ISO-2022-JPなど。")
    print("   対応ファイル: テキストベースのファイル (例: csv, txt, html, asp)。バイナリファイルは不可。")
    print(f"   制限: 最大ファイルサイズ {Config.MAX_FILE_SIZE // (1024*1024)}MB。")
    print("2. 使用方法: python3 program.py json_file.json")
    print("3. JSONファイル形式:")
    print("   {")
    print("     \"tool_input\": {")
    print("       \"file_path\": \"/path/to/target/file\"")
    print("     }")
    print("   }")
    print("4. Shift_JISで表現できない文字は代替文字[?]に置換されます。")

def _process_files_from_args(filepaths):
    """コマンドライン引数で渡されたファイルを処理し、結果を返す."""
    results = []
    converted_count = 0
    skipped_count = 0
    failed_count = 0
    total_count = len(filepaths)

    print("変換処理を開始します...")
    print(f"対象ファイル: {filepaths}")
    print("⚠️  元ファイルを直接上書きします(バックアップなし)\n")

    for i, filepath_arg in enumerate(filepaths, 1):
        try:
            original_filename = os.path.basename(filepath_arg) if filepath_arg else f"ファイル{i}"
            print(f"処理中 ({i}/{total_count}): {original_filename}")

            result = convert_to_sjis(filepath_arg)
            results.append((original_filename, result))

            if result['success']:
                if result.get('skipped', False):
                    skipped_count += 1
                elif result.get('converted', False):
                    converted_count += 1
            else:
                if not result.get('skipped', False):
                    failed_count +=1

        except Exception as e:
            print(f"エラーが発生しました: {e}", file=sys.stderr)
            failed_count += 1

    return results, converted_count, skipped_count, failed_count, total_count

def _display_conversion_summary(results, converted_count, skipped_count, failed_count, total_count):
    """変換結果の要約と詳細を表示する."""
    print(f"\n{'='*50}")
    print("=== 変換結果 ===")

    for fname, res_item in results:
        print(format_result_message(res_item, fname))

    summary_parts = []
    if converted_count > 0:
        summary_parts.append(f"{converted_count}ファイル変換")
    if skipped_count > 0:
        summary_parts.append(f"{skipped_count}ファイルスキップ")
    if failed_count > 0:
        summary_parts.append(f"{failed_count}ファイル失敗")

    summary_str = "、".join(summary_parts) if summary_parts else "処理対象なし"

    print(f"\n処理完了({total_count}ファイル中 {summary_str})")
    print(f"{'='*50}")

def main():
    try:
        if len(sys.argv) > 1:
            json_filepath = sys.argv[1]
            
            # JSONファイルの存在確認
            if not os.path.exists(json_filepath):
                print(f"エラー: JSONファイルが見つかりません: {json_filepath}")
                return
            
            # JSONファイルを読み込み
            try:
                with open(json_filepath, 'r', encoding='utf-8') as f:
                    json_data = json.load(f)
                
                # tool_input.file_pathを取得
                if 'tool_input' in json_data and 'file_path' in json_data['tool_input']:
                    file_path = json_data['tool_input']['file_path']
                    filepaths = [file_path]  # 配列として設定
                    print(f"JSONから取得したファイルパス: {file_path}")
                else:
                    print("エラー: JSONファイルにtool_input.file_pathが見つかりません")
                    return
                    
            except json.JSONDecodeError as e:
                print(f"エラー: JSONファイルの解析に失敗しました: {e}")
                return
            except Exception as e:
                print(f"エラー: JSONファイルの読み込みに失敗しました: {e}")
                return
            
            results, converted_count, skipped_count, failed_count, total_count = _process_files_from_args(filepaths)
            _display_conversion_summary(results, converted_count, skipped_count, failed_count, total_count)
        else:
            _display_help_message()

    except KeyboardInterrupt:
        print("\nプログラムが中断されました。")
    except Exception as e:
        import traceback
        print(f"\n重大なエラーが発生しました: {str(e)}", file=sys.stderr)
        print(f"{traceback.format_exc()}", file=sys.stderr)

    except (EOFError, KeyboardInterrupt):
        pass

if __name__ == "__main__":
    main()

参考 URL

本家
https://docs.anthropic.com/en/docs/claude-code/hooks-guide

hooks がまとまったサイト
https://smartscope.blog/AI/claude-code-hooks-advanced/

最終的に助けてくれたサイト
https://dev.classmethod.jp/articles/claude-code-hooks-basic-usage/