Skip to content

Commit b2f6720

Browse files
QUSETIONSclaude
andcommitted
feat: MINICODE_COMMAND_ENCODING override for cp936/GBK command output
On Chinese Windows (cp936 OEM code page), legacy commands (dir, systeminfo, batch scripts printing CJK) output GBK bytes; the UTF-8 default garbles them, and a "try UTF-8 then cp936" fallback can't help (GBK bytes are frequently valid UTF-8, decoding to wrong chars without ever falling back). Add a MINICODE_COMMAND_ENCODING env override (default utf-8). Setting it to cp936/gbk decodes such command output correctly. Verified end-to-end (GBK output decodes to the right CJK chars under cp936). A bad encoding name falls back to UTF-8 so execution never crashes. Documented in .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cbb941f commit b2f6720

3 files changed

Lines changed: 112 additions & 7 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ ANTHROPIC_MODEL=claude-sonnet-4-20250514
4545
MINI_CODE_LOG_LEVEL=WARNING
4646
# Options: DEBUG, INFO, WARNING, ERROR
4747

48+
# --- Command output encoding ---
49+
# Encoding used to decode run_command output. Defaults to utf-8.
50+
# On Chinese Windows, set to cp936 (or gbk) so legacy commands that output in
51+
# the OEM code page (dir, systeminfo, batch scripts printing CJK) decode
52+
# correctly instead of mojibake. Pick whichever matches the majority of your
53+
# commands; a single value can't cover mixed utf-8 + cp936 output perfectly.
54+
# MINI_CODE_COMMAND_ENCODING=cp936
55+
4856
# --- Workspace (for Docker) ---
4957
MINI_CODE_WORKSPACE=.
5058

minicode/tools/run_command.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,41 @@
1818
MAX_OUTPUT_CHARS = 200_000
1919

2020

21+
def _command_output_encoding() -> str:
22+
"""Encoding used to decode command output.
23+
24+
Override with the ``MINICODE_COMMAND_ENCODING`` env var — e.g. set it to
25+
``cp936`` (or ``gbk``) on Chinese Windows where legacy commands (``dir``,
26+
``systeminfo``, batch scripts printing CJK) output in the OEM code page and
27+
would otherwise garble under the UTF-8 default. Defaults to ``utf-8``
28+
(modern tools, and Python with ``PYTHONUTF8=1``).
29+
30+
A single global encoding can't be perfect for mixed output (a ``cp936``
31+
override will garble UTF-8 tools, and vice-versa) — pick whichever matches
32+
the majority of the commands you run.
33+
"""
34+
return os.environ.get("MINICODE_COMMAND_ENCODING", "utf-8").strip() or "utf-8"
35+
36+
37+
def _decode_command_output(data: bytes | str | None) -> str:
38+
"""Decode subprocess output bytes using the configured command encoding.
39+
40+
Never raises: an unknown encoding name or a decode failure falls back to
41+
UTF-8 with replacement chars so a bad ``MINICODE_COMMAND_ENCODING`` value
42+
can't crash command execution.
43+
"""
44+
if not data:
45+
return ""
46+
if isinstance(data, str):
47+
return data
48+
encoding = _command_output_encoding()
49+
try:
50+
return data.decode(encoding)
51+
except (UnicodeDecodeError, LookupError):
52+
return data.decode("utf-8", errors="replace")
53+
54+
55+
2156
def _truncate_large_output(output: str, max_chars: int = MAX_OUTPUT_CHARS) -> str:
2257
"""Truncate very large command output to prevent context bloat."""
2358
if len(output) <= max_chars:
@@ -338,7 +373,7 @@ def _run(input_data: dict, context) -> ToolResult:
338373
if not timed_out:
339374
process.wait()
340375

341-
output_str = output_bytes.decode("utf-8", errors="replace").strip()
376+
output_str = _decode_command_output(bytes(output_bytes)).strip()
342377
output_str = output_str.replace("\r\n", "\n")
343378
output_str = _truncate_large_output(output_str)
344379

@@ -359,19 +394,21 @@ def _run(input_data: dict, context) -> ToolResult:
359394
cwd=effective_cwd,
360395
env=os.environ.copy(),
361396
capture_output=True,
362-
text=True,
363-
encoding="utf-8", # 显式指定 UTF-8
364-
errors="replace", # 无法解码时替换字符而非报错
365397
check=False,
366398
timeout=effective_timeout,
367399
)
368-
output = "\n".join(part for part in [completed.stdout.strip(), completed.stderr.strip()] if part).strip()
400+
output = "\n".join(
401+
part for part in [
402+
_decode_command_output(completed.stdout).strip(),
403+
_decode_command_output(completed.stderr).strip(),
404+
] if part
405+
).strip()
369406
output = _truncate_large_output(output)
370407
return ToolResult(ok=completed.returncode == 0, output=output)
371408
except subprocess.TimeoutExpired as e:
372409
# Capture partial output from timeout
373-
partial_stdout = (e.stdout or "").strip() if e.stdout else ""
374-
partial_stderr = (e.stderr or "").strip() if e.stderr else ""
410+
partial_stdout = _decode_command_output(e.stdout).strip()
411+
partial_stderr = _decode_command_output(e.stderr).strip()
375412
partial = "\n".join(part for part in [partial_stdout, partial_stderr] if part)
376413
if partial:
377414
partial = f"\nPartial output:\n{_truncate_large_output(partial)}"

tests/test_run_command_encoding.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Tests for run_command output encoding (MINICODE_COMMAND_ENCODING).
2+
3+
On Chinese Windows (cp936/GBK OEM code page), legacy commands output GBK bytes.
4+
The default UTF-8 decode garbles them, and a "try UTF-8 first" fallback can't
5+
help (GBK bytes are frequently valid UTF-8 → wrong chars, no fallback). The
6+
fix is an explicit override via MINICODE_COMMAND_ENCODING.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import importlib
12+
13+
import pytest
14+
15+
16+
def _reload(monkeypatch):
17+
import minicode.tools.run_command as rc
18+
return importlib.reload(rc)
19+
20+
21+
def test_default_encoding_is_utf8(monkeypatch) -> None:
22+
monkeypatch.delenv("MINICODE_COMMAND_ENCODING", raising=False)
23+
rc = _reload(monkeypatch)
24+
assert rc._command_output_encoding() == "utf-8"
25+
assert rc._decode_command_output("中文".encode("utf-8")) == "中文"
26+
27+
28+
def test_cp936_override_decodes_gbk(monkeypatch) -> None:
29+
"""The case UTF-8-first can't handle: GBK bytes decode correctly under cp936."""
30+
monkeypatch.setenv("MINICODE_COMMAND_ENCODING", "cp936")
31+
rc = _reload(monkeypatch)
32+
assert rc._command_output_encoding() == "cp936"
33+
assert rc._decode_command_output("目录文件".encode("gbk")) == "目录文件"
34+
35+
36+
def test_gbk_alias_works(monkeypatch) -> None:
37+
monkeypatch.setenv("MINICODE_COMMAND_ENCODING", "gbk")
38+
rc = _reload(monkeypatch)
39+
assert rc._decode_command_output("目录".encode("gbk")) == "目录"
40+
41+
42+
def test_bad_encoding_name_falls_back_to_utf8(monkeypatch) -> None:
43+
"""An invalid MINICODE_COMMAND_ENCODING must not crash execution."""
44+
monkeypatch.setenv("MINICODE_COMMAND_ENCODING", "not-a-real-codec")
45+
rc = _reload(monkeypatch)
46+
assert rc._decode_command_output("hello".encode()) == "hello"
47+
48+
49+
def test_decode_passthrough_and_empty(monkeypatch) -> None:
50+
monkeypatch.delenv("MINICODE_COMMAND_ENCODING", raising=False)
51+
rc = _reload(monkeypatch)
52+
assert rc._decode_command_output(None) == ""
53+
assert rc._decode_command_output(b"") == ""
54+
assert rc._decode_command_output("already a str") == "already a str"
55+
56+
57+
def test_blank_override_falls_back_to_utf8(monkeypatch) -> None:
58+
monkeypatch.setenv("MINICODE_COMMAND_ENCODING", " ")
59+
rc = _reload(monkeypatch)
60+
assert rc._command_output_encoding() == "utf-8"

0 commit comments

Comments
 (0)