SLOPSHOPPER

encoding-guard

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 / Бережёт…

newguardtoastprocess
★ 1v1.0.0MITupdated 2026-10-26ArtyaEXE/claude-mods/encoding-guard
A shopper browsing a rack in a slop shop
README

encoding-guard

Keeps Cyrillic and other non-ASCII text intact in Claude's commands on Windows.

English · Русский

English

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:

  • reads UTF-8 in PowerShell: adds -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;
  • stops writes in cp1251: a 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;
  • runs Python and git in UTF-8: the session's commands get 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;
  • explains broken output to Claude: mojibake like Р...С..., � 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.

What it touches

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.

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.

Install

/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 видит кракозябры, принимает их за чистую монету и иногда записывает обратно в файл. Мод:

  • читает UTF-8 в PowerShell: добавляет -Encoding UTF8 к Get-Content (gc, cat, type) без -Encoding. Если файл оказался в cp1251, подсказывает Claude прочитать его с -Encoding Default;
  • не даёт записать в cp1251: Set-Content / Add-Content без -Encoding не запускается, если пишет не-ASCII текст или текст, прочитанный в той же команде; Claude получает совет использовать инструменты Edit / Write или писать UTF-8 без BOM;
  • запускает Python и git в UTF-8: команды сессии получают PYTHONUTF8=1 и core.quotepath=false для git (через переменные GIT_CONFIG_*: ваш конфиг git не меняется). Переменную, которую вы уже задали сами, мод не трогает;
  • объясняет Claude испорченный вывод: кракозябры вида Р...С..., � от утилит 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.

Source 1 files
hooks/register.ts 202 lines
1import 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