Read the script
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["chardet>=5,<6", "binaryornot"]
# ///
"""
encoding_guard.py - Preserve file encoding and line endings when Claude Code edits files.
Strategy:
1. PreToolUse (Read): detect encoding + line endings, convert to UTF-8 so Claude reads correctly
2. PreToolUse (Edit): file already UTF-8 (cache exists), skip
3. PostToolUse (Edit): restore original encoding + line endings from session cache
4. Stop / SessionEnd / SessionStart(resume) (restore-all):
restore any remaining files — turn cleanup, exit backstop, and
crash-residue healing before the resumed session acts
Recovery commands (user-invoked CLI, not hooks):
scan / scan-current read-only report of cache records
restore-current restore orphaned files under the cwd
prune-current delete obsolete records under the cwd
Cache layout:
<tempdir>/.cc_encoding_cache/<session_id>/<sha256(normalized_path)[:16]>.json
Each session gets its own cache directory. Hooks operate strictly on their
own session's records; only the user-invoked recovery commands may act on
other sessions' records. Stale EMPTY session dirs (>24h) are removed on
startup — record-bearing dirs are never auto-deleted.
Stdin is read as binary (sys.stdin.buffer) to avoid Windows codepage issues.
"""
# Read stdin immediately before any heavy imports (Windows stdin reliability).
# Hook modes only: CC feeds hooks a pipe that closes. In the recovery CLI
# modes stdin may be an interactive terminal, where a blind read() would
# block forever.
import sys
_raw_stdin = b""
if len(sys.argv) > 1 and sys.argv[1] in ("pre", "post", "restore-all"):
_raw_stdin = sys.stdin.buffer.read()
import contextlib
import json
import os
import hashlib
import tempfile
import time
CACHE_ROOT = os.path.join(tempfile.gettempdir(), ".cc_encoding_cache")
STALE_HOURS = 24
LOG_FILE = os.path.join(CACHE_ROOT, "encoding_guard.log")
LOG_MAX_BYTES = 512 * 1024
RESTORE_ENCODINGS = {
"gb2312", "gbk", "gb18030",
"big5", "big5hkscs",
"euc-tw",
"shift_jis", "euc-jp", "iso-2022-jp",
"euc-kr",
"windows-1252", "iso-8859-1",
"windows-1251",
}
ENCODING_ALIASES = {
"gb2312": "gbk",
"iso-8859-1": "windows-1252",
}
# Encodings whose chardet detection (>=0.5 confidence + name-in-RESTORE) is
# reliable enough to skip the binaryornot pre-check. binaryornot's decision
# tree false-positives mixed-content files in two patterns:
# - short CJK files (<~500B Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030)
# - mixed Cyrillic + ASCII files (e.g., source code with CP1251 comments)
# For these encodings chardet's structural validators are strict enough on
# their own; real binaries cannot reach the confidence threshold.
STRUCTURAL_TRUSTED = {
"gbk", "gb18030",
"big5", "big5hkscs",
"shiftjis", "eucjp", "euckr", "iso2022jp",
"windows1251",
}
def _strip_enc(name: str) -> str:
return name.lower().replace("-", "").replace("_", "")
def _log(msg: str):
sys.stderr.write(f"encoding_guard: {msg}\n")
def _flog(event: str):
"""Append one line to the forensic log (CACHE_ROOT/encoding_guard.log).
Hook stderr is captured into transcripts, but operations that run in
process-shutdown windows (e.g. SessionEnd restore-all after the
terminal is closed) never reach a transcript — this file is the
durable trail for every file mutation. Best-effort: never raises.
"""
try:
os.makedirs(CACHE_ROOT, exist_ok=True)
try:
if os.path.getsize(LOG_FILE) > LOG_MAX_BYTES:
os.replace(LOG_FILE, LOG_FILE + ".1")
except OSError:
pass
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"{stamp} {event}\n")
except OSError:
pass
def normalize_encoding(enc: str) -> str:
"""Map chardet output to a Python codec name.
Apply ENCODING_ALIASES safety mappings (gb2312 -> gbk, iso-8859-1 ->
windows-1252) when applicable; otherwise preserve chardet's original
name verbatim. Returning the stripped form (e.g. "windows1251" without
its dash) breaks codec lookup since Python's codec registry recognizes
"windows-1251" / "cp1251" but NOT "windows1251".
"""
stripped = _strip_enc(enc)
for orig, alias in ENCODING_ALIASES.items():
if stripped == _strip_enc(orig):
return alias
return enc
def normalize_path(path: str) -> str:
"""Normalize path for consistent cache keys across pre/post and platforms."""
return os.path.normcase(os.path.normpath(path))
def file_hash(path: str) -> str:
return hashlib.sha256(normalize_path(path).encode()).hexdigest()[:16]
def sanitize_session_id(session_id: str) -> str:
"""Prevent path traversal from untrusted session_id."""
return session_id.replace(os.sep, "_").replace("/", "_").replace("\\", "_")
def session_dir(session_id: str) -> str:
return os.path.join(CACHE_ROOT, sanitize_session_id(session_id))
def cache_path(session_id: str, path: str) -> str:
return os.path.join(session_dir(session_id), file_hash(path) + ".json")
def cleanup_stale_sessions(current_session_id: str):
"""Remove EMPTY stale session dirs only. Never touches another session's records.
An orphaned record is the only copy of "this file on disk should be
re-encoded back" — deleting it is irreversible information loss, and
restoring it automatically can override a user who has since accepted
the UTF-8 state. Cross-session records are therefore acted on only by
the user, via the scan/restore-current/prune-current commands.
"""
if not os.path.exists(CACHE_ROOT):
return
current_dir_name = sanitize_session_id(current_session_id)
now = time.time()
try:
for name in os.listdir(CACHE_ROOT):
if name == current_dir_name: