Development
Hook
Hooks
What encoding-guard runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add ymonster/claude_encoding_guard > /plugin install encoding-guard@claude-encoding-guard
Ships with encoding-guard. Installing the plugin gets these hooks.
What fires, and when
PreToolUse
- Matches
Read|Edit|Writeuv run --script "${CLAUDE_PLUGIN_ROOT}/hooks/encoding_guard.py" pre
PostToolUse
- Matches
Edit|Writeuv run --script "${CLAUDE_PLUGIN_ROOT}/hooks/encoding_guard.py" post
Stop
uv run --script "${CLAUDE_PLUGIN_ROOT}/hooks/encoding_guard.py" restore-all
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
- Matches
resumeuv run --script "${CLAUDE_PLUGIN_ROOT}/hooks/encoding_guard.py" restore-all
SessionEnd
uv run --script "${CLAUDE_PLUGIN_ROOT}/hooks/encoding_guard.py" restore-all
In the plugin's words
How encoding-guard describes its own hook set.
Encoding guard hooks - preserve non-UTF-8 file encodings when Claude Code edits
Where it lives
- hooks/encoding_guard.pyRunsGitHub
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:
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withencoding-guard
Preserve non-UTF-8 file encodings and line endings when Claude Code edits your files.
Get the whole plugin

