Кириллический резидент передаёт из Клодляндии

Как уже в курсе любимый читатель, мы живем в эпоху искусственного интеллекта и агентской разработки. ИИшечки пишут код, код работает, все счастливы. Но есть одна проблема — всё около-иишное как правило живёт и мыслится в окружении macOS/Linux и в контексте в первую очередь питона. А что делать, если ты хочешь использовать иишку для разработки на павершелле и использовать Клода в среде Виндоуз? Ответ по умолчанию — страдать.

Чтобы не было проблем с кириллицей, скрипты нужно хранить в UTF-16 LE с BOM — так их одинаково однозначно опознают что PowerShell 5.1, что pwsh 7+, что тестовые редакторы под Windows, что git/gitlab, и все живут дружно. А вот Claude Code с его встроенными инструментами `Edit` и `Write` живёт только в своей реальности UTF-8 и плевать хотел, в какой кодировке был файл. А поэтому и читать-редактировать старые наработки по умолчанию не может, и новое пишет сломанное.

Проблема старая и известная, но как ’это часто бывает с корнер кейсами, проблемы индейцев шерифа не волнуют — ни фикса, ни какой-либо настройки для клода не существует и, скорее всего, не предвидится. ИЧСХ, проблема тут не только кириллическая, у японцев например ровно та же беда, только с Shift_JIS вместо UTF-16. Но кому какое дело до этих ваших туземных особенностей?

карта мира по мнению программистов (гипотеза)

Сразу как только я наступил на эти грабли, родился обходной маневр: инструкцией в CLAUDE.md агенту было сказано для редактирования PowerShell скриптов делать построчную замену вызовом того же PowerShell. И это в целом работало, но сильно замедляло (и часто ломало) процесс — решение было кустарным, хрупким, и разрешение спрашивалось на каждый чих. Агент регулярно забывал инструкции, а чтобы вспомнить сначала бегло бежал по садовому инвентарю, отнимая моё время, нервные клетки, и тратя драгоценные токены.

Сегодня, устав от очередной порчи скрипта при небольшом изменении, я отправил агента в рисёрч с требованием раз и навсегда решить проблему в корне. Клод обнаружил в интернетах братьев-японцев и других потерпевших, но главное — придумал использовать хуки, которые прозрачно конвертируют файл туда-обратно при редактировании встроенными инструментами. Я затестил и это работает. Щас расскажу.

Доктор Крюк, или как я перестал страдать и полюбил хуки

У Claude Code есть `PreToolUse`/`PostToolUse`/`Stop` hooks — команды, которые выполняются автоматически вокруг вызовов инструментов. Идея простая: пусть файл на диске «в покое» всегда лежит в UTF-16 LE, а хуки на лету превращают его в UTF-8 непосредственно перед тем, как его коснётся Read/Edit/Write, и обратно — сразу после. Модель вообще не должна догадываться, что файл когда-то был не в UTF-8.

Настройки хуков в  ~/.claude/settings.json:

"hooks": {
  "PreToolUse": [
    {
      "matcher": "Read|Edit",
      "hooks": [{
        "type": "command",
        "command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
        "args": ["-NoProfile", "-File", "C:\\Users\\[USER]\\.claude\\hooks\\ps1-pre-encoding.ps1"]
      }]
    }
  ],
  "PostToolUse": [
    {
      "matcher": "Edit|Write",
      "hooks": [{
        "type": "command",
        "command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
        "args": ["-NoProfile", "-File", "C:\\Users\\[USER]\\.claude\\hooks\\ps1-post-encoding.ps1"]
      }]
    }
  ],
  "Stop": [
    {
      "hooks": [{
        "type": "command",
        "command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
        "args": ["-NoProfile", "-File", "C:\\Users\\[USER]\\.claude\\hooks\\ps1-restore-all.ps1"]
      }]
    }
  ]
}

pre-encoding.ps1 (перед Read/Edit — если увидел BOM UTF-16 LE, конвертирует в UTF-8 и запоминает путь):

$ErrorActionPreference = 'Stop'
$stateFile = "C:\Users\[USER]\.claude\hooks\ps1-utf16-state.json"
$scopeRoot = "[ВАШ ПУТЬ]"

try {
    $inputJson = [Console]::In.ReadToEnd()
    $data = $inputJson | ConvertFrom-Json
    $filePath = $data.tool_input.file_path

    if (-not $filePath) { exit 0 }
    if ($filePath -notmatch '\.ps1$') { exit 0 }
    if (-not $filePath.StartsWith($scopeRoot, [System.StringComparison]::OrdinalIgnoreCase)) { exit 0 }
    if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { exit 0 }

    $bytes = [System.IO.File]::ReadAllBytes($filePath)
    if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        $content = [System.IO.File]::ReadAllText($filePath, [System.Text.Encoding]::Unicode)
        [System.IO.File]::WriteAllText($filePath, $content, (New-Object System.Text.UTF8Encoding($false)))

        $state = @()
        if (Test-Path -LiteralPath $stateFile) {
            $raw = Get-Content -LiteralPath $stateFile -Raw -ErrorAction SilentlyContinue
            if ($raw) { $state = @($raw | ConvertFrom-Json) }
        }
        if ($state -notcontains $filePath) { $state += $filePath }
        ($state | ConvertTo-Json) | Set-Content -LiteralPath $stateFile -Encoding utf8
    }
} catch {
    exit 0
}
exit 0

post-encoding.ps1 (после Edit/Write — конвертирует обратно в UTF-16 LE, но только файлы из стейта):

$ErrorActionPreference = 'Stop'
$stateFile = "C:\Users\[USER]\.claude\hooks\ps1-utf16-state.json"

try {
    $inputJson = [Console]::In.ReadToEnd()
    $data = $inputJson | ConvertFrom-Json
    $filePath = $data.tool_input.file_path

    if (-not $filePath) { exit 0 }
    if ($filePath -notmatch '\.ps1$') { exit 0 }
    if (-not (Test-Path -LiteralPath $stateFile)) { exit 0 }

    $raw = Get-Content -LiteralPath $stateFile -Raw -ErrorAction SilentlyContinue
    if (-not $raw) { exit 0 }
    $state = @($raw | ConvertFrom-Json)
    if ($state -notcontains $filePath) { exit 0 }
    if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { exit 0 }

    $content = [System.IO.File]::ReadAllText($filePath, [System.Text.Encoding]::UTF8)
    [System.IO.File]::WriteAllText($filePath, $content, [System.Text.Encoding]::Unicode)

    $state = @($state | Where-Object { $_ -ne $filePath })
    ($state | ConvertTo-Json) | Set-Content -LiteralPath $stateFile -Encoding utf8
} catch {
    exit 0
}
exit 0

restore-all.ps1 (Stop-хук — подчищает в конце хода всё, что осталось не восстановленным после одиночного Read без последующего Edit):

$ErrorActionPreference = 'Stop'
$stateFile = "C:\Users\[USER]\.claude\hooks\ps1-utf16-state.json"

try {
    [Console]::In.ReadToEnd() | Out-Null

    if (-not (Test-Path -LiteralPath $stateFile)) { exit 0 }
    $raw = Get-Content -LiteralPath $stateFile -Raw -ErrorAction SilentlyContinue
    if (-not $raw) { exit 0 }
    $state = @($raw | ConvertFrom-Json)

    foreach ($filePath in $state) {
        if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { continue }
        $bytes = [System.IO.File]::ReadAllBytes($filePath)
        $isUtf16Le = $bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE
        if (-not $isUtf16Le) {
            $content = [System.IO.File]::ReadAllText($filePath, [System.Text.Encoding]::UTF8)
            [System.IO.File]::WriteAllText($filePath, $content, [System.Text.Encoding]::Unicode)
        }
    }

    Set-Content -LiteralPath $stateFile -Value "[]" -Encoding utf8
} catch {
    exit 0
}
exit 0

Собственно, делать эти настройки руками не нужно — просто скопипастите текст заметки в чат своему Клоду и попросите настроить так же.

Доклад окончен

Скрипты на диске и в git теперь всегда лежат в UTF-16 LE с BOM — ровно так, как его и ждёт PowerShell. Пока файла касается Claude Code — он на долю секунды «мигает» в UTF-8 и обратно, а модель работает через свои обычные беззаботные Edit/Write. Роботы работают, человек не напрягается.

Важная тонкость — в скрипты зашита проверка на них самих, чтобы создавая-редактируя их Клод не попадал в бесконечную кодировочную петлю.

Пойду, завайбкожу ещё что-нибудь.

Слава роботам! КЭНК!