Keeps Cyrillic and other non-ASCII text intact on Windows: UTF-8 reads in PowerShell 5.1, Python and git in UTF-8, mojibake explained to Claude / Бережёт…

Keeps Cyrillic and other non-ASCII text intact in Claude's commands on Windows.
On a Windows with a Russian (or any non-UTF-8) system locale, Claude's commands mangle text in several quiet ways: PowerShell 5.1 reads UTF-8 files without a BOM as cp1251 and saves with Set-Content in cp1251, Python in Git Bash fails with UnicodeEncodeError, git prints "\321\204\320\260..." instead of a file name, cmd tools print �. Claude then sees mojibake, takes it at face value and sometimes writes it back to a file. This mod:
-Encoding UTF8 to Get-Content (gc, cat, type) that has no -Encoding. If the file turns out to be cp1251, it tells Claude to read it with -Encoding Default;Set-Content / Add-Content without -Encoding is not run when it writes non-ASCII text or text read in the same command, and Claude is told to use the Edit / Write tool or to write UTF-8 without a BOM;PYTHONUTF8=1 and git's core.quotepath=false (through GIT_CONFIG_* variables: your git config is not changed). A variable you already set is left as it is;Р...С..., � from cmd tools, Python's UnicodeEncodeError, git's octal paths each get a short note with what to do instead.A command containing # encoding:off is passed through untouched.
Rewrites PowerShell commands that read files with Get-Content without -Encoding (adds -Encoding UTF8); refuses the PowerShell writes described above. Sets PYTHONUTF8, GIT_CONFIG_COUNT, GIT_CONFIG_KEY_0, GIT_CONFIG_VALUE_0 for the Claude Code process and the commands it starts, on Windows only. Adds notes for Claude to command results. Changes no files and no settings.
/config)language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.sessionEnv: on (default) sets the UTF-8 variables above; off leaves the environment alone and only gives hints./plugin install encoding-guard --marketplace ArtyaEXE/claude-mods
Part of claude-mods. MIT license.
Бережёт кириллицу и прочий не-ASCII текст в командах Claude на Windows.
На Windows с русской (или любой не-UTF-8) системной локалью команды Claude тихо портят текст: PowerShell 5.1 читает UTF-8 файлы без BOM как cp1251 и сохраняет через Set-Content в cp1251, Python в Git Bash падает с UnicodeEncodeError, git вместо имени файла печатает "\321\204\320\260...", утилиты cmd выводят �. Claude видит кракозябры, принимает их за чистую монету и иногда записывает обратно в файл. Мод:
-Encoding UTF8 к Get-Content (gc, cat, type) без -Encoding. Если файл оказался в cp1251, подсказывает Claude прочитать его с -Encoding Default;Set-Content / Add-Content без -Encoding не запускается, если пишет не-ASCII текст или текст, прочитанный в той же команде; Claude получает совет использовать инструменты Edit / Write или писать UTF-8 без BOM;PYTHONUTF8=1 и core.quotepath=false для git (через переменные GIT_CONFIG_*: ваш конфиг git не меняется). Переменную, которую вы уже задали сами, мод не трогает;Р...С..., � от утилит cmd, UnicodeEncodeError в Python, восьмеричные пути в git — на каждое короткая заметка, что делать вместо этого.Команду со строкой # encoding:off мод пропускает без изменений.
Переписывает команды PowerShell, читающие файлы через Get-Content без -Encoding (добавляет -Encoding UTF8); не запускает описанные выше записи в PowerShell. Задаёт PYTHONUTF8, GIT_CONFIG_COUNT, GIT_CONFIG_KEY_0, GIT_CONFIG_VALUE_0 процессу Claude Code и командам, которые он запускает, только на Windows. Добавляет к результатам команд заметки для Claude. Файлы и настройки не меняет.
/config)language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en.sessionEnv: on (по умолчанию) задаёт переменные UTF-8, описанные выше; off не трогает окружение и только подсказывает./plugin install encoding-guard --marketplace ArtyaEXE/claude-mods
Часть claude-mods. Лицензия MIT.
hooks/register.ts 202 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Lang = 'ru' | 'en'
4
5// `ru` / `en` as set; `auto`: the locale variables first, then Windows' UI language or regional format.
6async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
7 if (setting === 'ru' || setting === 'en') return setting
8 const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
9 if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
10 try {
11 const ran = await $.process.run(
12 ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
13 { timeoutMs: 10_000 },
14 )
15 if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
16 } catch {
17 // Not Windows: English.
18 }
19
20 return 'en'
21}
22
23// A command holding this marker is passed through untouched.
24const OPT_OUT = '# encoding:off'
25
26// Where a command word starts: the line's head, after a separator, an opening bracket or an assignment.
27const HEAD = String.raw`(?:^|[;|{(=\n]\s*)`
28
29const READ = new RegExp(HEAD + String.raw`(Get-Content|gc|cat|type)(?=\s|$)`, 'gi')
30const WRITE = new RegExp(HEAD + String.raw`(Set-Content|Add-Content|ac)(?=\s|$)`, 'gi')
31
32// Any parameter that starts like -Encoding: the command already says what it means.
33const HAS_ENCODING = /\s-en/i
34// A provider path other than the file system, where -Encoding does not exist.
35const NOT_A_FILE = /(?:^|[\s'"])(?:env|variable|function|alias|hklm|hkcu|cert|wsman):/i
36
37// The command with quoted strings, here-strings and comments blanked to spaces, length kept,
38// so an index into it is an index into the command.
39function codeOf(command: string): string {
40 const blank = (s: string) => s.replace(/[^\n]/g, ' ')
41
42 return command
43 .replace(/@'[\s\S]*?'@|@"[\s\S]*?"@/g, blank)
44 .replace(/'[^'\n]*'|"(?:[^"`\n]|`.)*"/g, s => s[0] + blank(s.slice(1, -1)) + s[s.length - 1])
45 .replace(/(^|\s)#.*$/gm, (s, lead: string) => lead + blank(s.slice(lead.length)))
46}
47
48// From a cmdlet to the end of its pipeline stage.
49function stageAt(code: string, from: number): string {
50 const end = code.slice(from).search(/[;|\n}]/)
51
52 return end === -1 ? code.slice(from) : code.slice(from, from + end)
53}
54
55// `Get-Content` and its aliases without -Encoding read UTF-8 files without a BOM as cp1251:
56// the command with ` -Encoding UTF8` after each of them, or the command itself when none needed it.
57function withUtf8Reads(command: string): string {
58 const code = codeOf(command)
59 const at: number[] = []
60 for (const m of code.matchAll(READ)) {
61 const name = m[1] ?? ''
62 const start = (m.index ?? 0) + m[0].length - name.length
63 const stage = stageAt(code, start)
64 if (!HAS_ENCODING.test(stage) && !NOT_A_FILE.test(stageAt(command, start))) at.push(start + name.length)
65 }
66
67 return at.reduceRight((out, i) => `${out.slice(0, i)} -Encoding UTF8${out.slice(i)}`, command)
68}
69
70// `Set-Content` / `Add-Content` without -Encoding write cp1251: harmful when the text is not ASCII,
71// or when it came from a read in the same command.
72function writesAnsi(command: string): boolean {
73 const code = codeOf(command)
74 const risky = /[^\x00-\x7F]/.test(command) || new RegExp(READ.source, 'i').test(code)
75 if (!risky) return false
76 for (const m of code.matchAll(WRITE)) {
77 const name = m[1] ?? ''
78 const start = (m.index ?? 0) + m[0].length - name.length
79 if (!HAS_ENCODING.test(stageAt(code, start))) return true
80 }
81
82 return false
83}
84
85// cp1251 bytes 0x80-0xBF as characters: the second half of every Cyrillic letter's UTF-8 bytes read as cp1251.
86const CP1251_HIGH =
87 'ЂЃ‚ѓ„…†‡€‰Љ‹ЊЌЋЏ' +
88 'ђ‘’“”•–—™љ›њќћџ' +
89 ' ЎўЈ¤Ґ¦§Ё©Є«¬®Ї' +
90 '°±Ііґµ¶·ё№є»јЅѕї'
91
92// Three Cyrillic letters or more in a row, each read as `Р` or `С` and one of the characters above.
93const MOJIBAKE = new RegExp(`(?:[\\u0420\\u0421][${CP1251_HIGH}]){3,}`)
94
95const REPLACEMENT = /�[^�]{0,3}�/
96
97// Programs that print in the OEM code page (cp866 on a Russian Windows).
98const OEM_TOOL =
99 /(?:^|[\s;&|/\\(])(?:cmd|ipconfig|ping|netstat|tasklist|taskkill|systeminfo|where|findstr|net|schtasks|wmic|reg|robocopy|xcopy|sc|chcp|nslookup|tracert|getmac|driverquery|whoami|dir|tree)(?:\.exe)?(?=[\s"']|$)/i
100
101const HINTS = {
102 mojibake:
103 'The output above contains UTF-8 text decoded as cp1251 (mojibake like `Р...С...`): it is not what the source says. ' +
104 'In PowerShell 5.1 read UTF-8 files with `Get-Content -Encoding UTF8` (`Import-Csv -Encoding UTF8` likewise); ' +
105 "for a program's output set `[Console]::OutputEncoding = [Text.Encoding]::UTF8` first. Never write this text back to a file.",
106 notUtf8:
107 'The file is not UTF-8 (the � characters), most likely cp1251: read it with `Get-Content -Encoding Default`. ' +
108 'encoding-guard added `-Encoding UTF8` to the read; an explicit `-Encoding` is left alone.',
109 oem:
110 'This Windows program printed in the OEM code page (cp866), which Git Bash cannot decode (the � characters). ' +
111 'Run it through the PowerShell tool instead: its console is UTF-8.',
112 python:
113 'Python wrote non-ASCII text to a cp1251 stdout. Run it in UTF-8 mode: `PYTHONUTF8=1 python ...` in Git Bash, ' +
114 "`$env:PYTHONUTF8 = '1'; python ...` in PowerShell, or call `sys.stdout.reconfigure(encoding='utf-8')` in the script.",
115 git: 'git printed non-ASCII paths as octal escapes: run it as `git -c core.quotepath=false ...` to see them as text.',
116 reads:
117 'Get-Content ran with `-Encoding UTF8`: PowerShell 5.1 otherwise reads UTF-8 files without a BOM as cp1251. ' +
118 'For a file that really is cp1251, pass `-Encoding Default` yourself.',
119 writes:
120 'Not run: in PowerShell 5.1 `Set-Content` / `Add-Content` without `-Encoding` save text as cp1251 (ANSI), and `Get-Content` ' +
121 'without `-Encoding` reads UTF-8 files as cp1251, so non-ASCII text would end up in the wrong encoding. ' +
122 'Change files with the Edit or Write tool. In a script, read with `[IO.File]::ReadAllText($path)` and write UTF-8 without a BOM: ' +
123 '`[IO.File]::WriteAllText($path, $text, [Text.UTF8Encoding]::new($false))`. ' +
124 `If the line is right as it is, add \`${OPT_OUT}\` to it.`,
125}
126
127const STOPPED = {
128 ru: 'encoding-guard: остановил запись, которая сохранила бы текст в cp1251',
129 en: 'encoding-guard: stopped a write that would have saved the text as cp1251',
130}
131
132// What to tell the model about a result, given the command that produced it.
133function hintsAfter(tool: 'PowerShell' | 'Bash', command: string, text: string, readsRewritten: boolean): string[] {
134 const hints: string[] = []
135 if (MOJIBAKE.test(text)) hints.push(HINTS.mojibake)
136 if (REPLACEMENT.test(text)) {
137 if (tool === 'PowerShell' && readsRewritten) hints.push(HINTS.notUtf8)
138 else if (tool === 'Bash' && OEM_TOOL.test(command)) hints.push(HINTS.oem)
139 }
140 if (/UnicodeEncodeError: '(?:charmap|cp1251|cp866)' codec/.test(text)) hints.push(HINTS.python)
141 if (/"[^"\n]*(?:\\[23][0-7]{2}){2}/.test(text) && /(?:^|[\s;&|(])git\s/.test(command)) hints.push(HINTS.git)
142
143 return hints
144}
145
146let lang: Lang = 'en'
147let toldAboutReads = false
148
149// UTF-8 for the session's commands: Python's UTF-8 mode, git's paths as text (through GIT_CONFIG_*,
150// so no config file changes). A variable the person set already is left alone.
151async function setSessionEnv($: EngineInterface) {
152 if ((await $.env.get('OS')) !== 'Windows_NT') return
153 if ((await $.env.get('PYTHONUTF8')) === undefined) await $.env.set('PYTHONUTF8', '1')
154 const count = await $.env.get('GIT_CONFIG_COUNT')
155 if (count === undefined || ((await $.env.get('GIT_CONFIG_KEY_0')) === 'core.quotepath' && count === '1')) {
156 await $.env.set('GIT_CONFIG_COUNT', '1')
157 await $.env.set('GIT_CONFIG_KEY_0', 'core.quotepath')
158 await $.env.set('GIT_CONFIG_VALUE_0', 'false')
159 }
160}
161
162export const register: Register = (on, options) => {
163 const o = options as { language?: unknown; sessionEnv?: unknown }
164
165 on('session.start', async ($, e, next) => {
166 const started = await next(e)
167 lang = await resolveLang($, o.language)
168 toldAboutReads = false
169 if (o.sessionEnv !== 'off') await setSessionEnv($)
170
171 return started
172 })
173
174 on('tool.call', async ($, e, next) => {
175 if (e.tool !== 'PowerShell' && e.tool !== 'Bash') return next(e)
176 const command = String((e as { command?: unknown }).command ?? '')
177 if (command.includes(OPT_OUT)) return next(e)
178
179 let run = command
180 if (e.tool === 'PowerShell') {
181 if (writesAnsi(command)) {
182 $.ui.toast(STOPPED[lang])
183
184 return { deny: HINTS.writes }
185 }
186 run = withUtf8Reads(command)
187 }
188 const rewritten = run !== command
189
190 const ran = await next(rewritten ? { ...e, command: run } : e)
191 if (ran.deny !== undefined) return ran
192 const hints = hintsAfter(e.tool, command, ran.text ?? '', rewritten)
193 if (rewritten && !toldAboutReads) {
194 toldAboutReads = true
195 hints.unshift(HINTS.reads)
196 }
197 if (hints.length === 0) return ran
198
199 return { ...ran, context: [...(ran.context ?? []), `encoding-guard: ${hints.join(' ')}`] }
200 })
201}
202