-
-
Save yuxi-liu-wired/c46cc9e25779968ca3227e3d270aaa0e to your computer and use it in GitHub Desktop.
| #!/usr/bin/env python3 | |
| """Patch Claude Code binary to blank all ASCII art (hat, Clawd, welcome screen). | |
| Art characters are stored as JS unicode escapes like \u2591 or \u259F | |
| (6 ASCII bytes each, mixed case hex). We replace them with \u200B | |
| (zero-width space), also 6 bytes, so art vanishes without corrupting | |
| the binary. | |
| Works even while Claude Code is running via atomic rename. | |
| Usually called by the wrapper at ~/.pixi/bin/claude with --single. | |
| Usage: | |
| python3 erase_ascii_claude_code.py # patch all binaries + stamp | |
| python3 erase_ascii_claude_code.py --single <path> # patch one binary + stamp | |
| python3 erase_ascii_claude_code.py --restore # restore current binary from backup | |
| """ | |
| import os | |
| import sys | |
| CLAUDE_LINK = os.path.expanduser("~/.local/bin/claude") | |
| VERSIONS_DIR = os.path.expanduser("~/.local/share/claude/versions") | |
| # All block element codepoints used in the art | |
| ART_CODEPOINTS = [ | |
| '2580', # ▀ UPPER HALF BLOCK | |
| '2584', # ▄ LOWER HALF BLOCK | |
| '2588', # █ FULL BLOCK | |
| '258C', # ▌ LEFT HALF BLOCK | |
| '2590', # ▐ RIGHT HALF BLOCK | |
| '2591', # ░ LIGHT SHADE | |
| '2592', # ▒ MEDIUM SHADE | |
| '2593', # ▓ DARK SHADE | |
| '2596', # ▖ QUADRANT LOWER LEFT | |
| '2597', # ▗ QUADRANT LOWER RIGHT | |
| '2598', # ▘ QUADRANT UPPER LEFT | |
| '2599', # ▙ QUADRANT UL+LL+LR | |
| '259B', # ▛ QUADRANT UL+UR+LL | |
| '259C', # ▜ QUADRANT UL+UR+LR | |
| '259D', # ▝ QUADRANT UPPER RIGHT | |
| '259F', # ▟ QUADRANT UR+LL+LR | |
| '2026', # … HORIZONTAL ELLIPSIS (art separator lines) | |
| '273B', # ✻ TEARDROP-SPOKED ASTERISK | |
| ] | |
| ZWSP_6 = b'\\u200B' # zero-width space escape, same 6 bytes | |
| def resolve_binary(): | |
| """Follow the claude symlink to find the actual binary.""" | |
| if not os.path.islink(CLAUDE_LINK): | |
| sys.exit(f"ERROR: {CLAUDE_LINK} is not a symlink. Can't resolve binary path.") | |
| target = os.path.realpath(CLAUDE_LINK) | |
| if not os.path.isfile(target): | |
| sys.exit(f"ERROR: Resolved path {target} does not exist.") | |
| return target | |
| def find_all_binaries(): | |
| """Find all executable binaries in the versions directory.""" | |
| if not os.path.isdir(VERSIONS_DIR): | |
| sys.exit(f"ERROR: {VERSIONS_DIR} does not exist.") | |
| binaries = [] | |
| for entry in os.listdir(VERSIONS_DIR): | |
| path = os.path.join(VERSIONS_DIR, entry) | |
| if os.path.isfile(path) and os.access(path, os.X_OK): | |
| binaries.append(path) | |
| return sorted(binaries) | |
| def wait_for_stable(path, interval=5, rounds=3): | |
| """Wait until a file's size and mtime stop changing. | |
| Checks every `interval` seconds; needs `rounds` consecutive | |
| identical readings before returning. Gives up after 2 minutes. | |
| """ | |
| import time | |
| deadline = time.monotonic() + 120 | |
| prev = None | |
| stable = 0 | |
| while time.monotonic() < deadline: | |
| try: | |
| st = os.stat(path) | |
| cur = (st.st_size, st.st_mtime) | |
| except OSError: | |
| cur = None | |
| if cur == prev: | |
| stable += 1 | |
| if stable >= rounds: | |
| return | |
| else: | |
| stable = 0 | |
| prev = cur | |
| time.sleep(interval) | |
| print(f"WARNING: {path} still changing after 2 min, patching anyway.") | |
| def build_escape_list(): | |
| """Build list of both upper and lowercase \\uXXXX escapes.""" | |
| seen = set() | |
| for cp in ART_CODEPOINTS: | |
| seen.add(b'\\u' + cp.upper().encode()) | |
| seen.add(b'\\u' + cp.lower().encode()) | |
| return list(seen) | |
| def do_patch(binary): | |
| import tempfile | |
| # Parent of versions dir: same filesystem, outside the watched directory. | |
| # All temp/backup writes go here to avoid inotify retriggers. | |
| parent = os.path.dirname(os.path.dirname(binary)) | |
| version = os.path.basename(binary) | |
| backup = os.path.join(parent, version + '.bak') | |
| # Always read the live binary to check current state. | |
| # If already patched, we find 0 matches and exit without writing, | |
| # which avoids retriggering the systemd path watcher. | |
| print(f"Reading from: {binary}") | |
| with open(binary, 'rb') as f: | |
| data = bytearray(f.read()) | |
| orig_size = len(data) | |
| print(f"Binary size: {orig_size} bytes") | |
| escapes = build_escape_list() | |
| print(f"Searching for {len(escapes)} escape variants...") | |
| total = 0 | |
| for esc in sorted(escapes): | |
| count = 0 | |
| offset = 0 | |
| while True: | |
| idx = data.find(esc, offset) | |
| if idx == -1: | |
| break | |
| data[idx:idx+6] = ZWSP_6 | |
| count += 1 | |
| offset = idx + 6 | |
| if count > 0: | |
| print(f" {esc.decode()}: {count}") | |
| total += count | |
| assert len(data) == orig_size, "Size mismatch after patching!" | |
| print(f"\nTotal replacements: {total}") | |
| if total == 0: | |
| print("Already patched or no art found — skipping.") | |
| return | |
| # Back up the unpatched binary (only if we haven't already) | |
| if not os.path.exists(backup): | |
| print(f"Creating backup: {backup}") | |
| with open(binary, 'rb') as f: | |
| orig_data = f.read() | |
| with open(backup, 'wb') as f: | |
| f.write(orig_data) | |
| os.chmod(backup, os.stat(binary).st_mode) | |
| # Write temp file outside the watched versions dir | |
| fd, patched = tempfile.mkstemp(prefix='claude-dehat-', dir=parent) | |
| os.close(fd) | |
| print(f"Writing to {patched}...") | |
| with open(patched, 'wb') as f: | |
| f.write(data) | |
| os.chmod(patched, os.stat(binary).st_mode) | |
| print(f"Atomic rename -> {binary}") | |
| os.rename(patched, binary) | |
| print(f"\nDone! {total} art escapes blanked.") | |
| def do_restore(binary): | |
| import shutil | |
| import tempfile | |
| parent = os.path.dirname(os.path.dirname(binary)) | |
| version = os.path.basename(binary) | |
| backup = os.path.join(parent, version + '.bak') | |
| # Also check old backup location for backwards compat | |
| old_backup = binary + '.bak' | |
| if not os.path.exists(backup) and os.path.exists(old_backup): | |
| backup = old_backup | |
| if not os.path.exists(backup): | |
| sys.exit(f"ERROR: No backup found at {backup}") | |
| fd, patched = tempfile.mkstemp(prefix='claude-dehat-', dir=parent) | |
| os.close(fd) | |
| shutil.copy2(backup, patched) | |
| os.rename(patched, binary) | |
| print(f"Restored {binary} from {backup}.") | |
| STAMP_DIR = os.path.expanduser("~/.local/share/claude/dehat-stamps") | |
| def stamp_path(binary): | |
| return os.path.join(STAMP_DIR, os.path.basename(binary) + '.dehatted') | |
| def write_stamp(binary): | |
| os.makedirs(STAMP_DIR, exist_ok=True) | |
| open(stamp_path(binary), 'w').close() | |
| def main(): | |
| if '--restore' in sys.argv: | |
| binary = resolve_binary() | |
| print(f"Claude binary: {binary}") | |
| do_restore(binary) | |
| elif '--single' in sys.argv: | |
| idx = sys.argv.index('--single') | |
| binary = sys.argv[idx + 1] | |
| if os.path.exists(stamp_path(binary)): | |
| sys.exit(0) | |
| do_patch(binary) | |
| write_stamp(binary) | |
| else: | |
| binaries = find_all_binaries() | |
| if not binaries: | |
| sys.exit("ERROR: No binaries found in versions directory.") | |
| print(f"Found {len(binaries)} binary/ies in {VERSIONS_DIR}") | |
| for binary in binaries: | |
| print(f"\n--- {os.path.basename(binary)} ---") | |
| wait_for_stable(binary) | |
| do_patch(binary) | |
| write_stamp(binary) | |
| if __name__ == '__main__': | |
| main() |
Claude Code displays ASCII art (a hat, a mascot, a welcome screen) on startup using Unicode block elements. This patches them out by replacing the escape sequences in the binary with zero-width spaces, which are the same byte length, so the binary is not corrupted.
The previous approach used a systemd path watcher to react to auto-updates, but this has a race condition - Claude Code can load the new binary before the watcher finishes patching. The current approach uses a shell wrapper that sits first in PATH and patches the binary before launching it. No race condition is possible because patching is a precondition of launch, not a reaction to an update.
The system has three parts:
~/.pixi/bin/claude- a shell wrapper that intercepts everyclaudeinvocation. It checks for a stamp file; if the stamp is missing, it patches the binary before exec. When the stamp exists, overhead is two stat calls.~/.claude/erase_ascii_claude_code.py- the patching script. Replaces Unicode block element escapes (\u2580,\u2588, etc.) with\u200B(zero-width space). Supports--single <path>for the wrapper to patch one binary.~/.local/share/claude/dehat-stamps/- one.dehattedfile per patched binary version. The wrapper skips patching if the stamp exists.
The auto-updater manages ~/.local/bin/claude (a symlink to the current version). The wrapper at ~/.pixi/bin/claude is earlier in PATH, so it intercepts the call, and the updater never touches it.
When a new version lands (say 2.1.73), the first launch takes a few seconds to read and patch the binary. Every subsequent launch is instant.
Setup:
- Create the stamp directory:
mkdir -p ~/.local/share/claude/dehat-stamps- Place the wrapper at
~/.pixi/bin/claude(or wherever is first in yourPATHbefore~/.local/bin):
#!/bin/bash
target=$(readlink -f ~/.local/bin/claude)
stamp=~/.local/share/claude/dehat-stamps/$(basename "$target")
[ -f "$stamp" ] || python3 ~/.claude/erase_ascii_claude_code.py --single "$target"
exec "$target" "$@"- Make it executable:
chmod +x ~/.pixi/bin/claude-
Place
erase_ascii_claude_code.pyat~/.claude/erase_ascii_claude_code.py. The full script is in this gist. -
Stamp all currently patched binaries so the wrapper does not re-scan them:
for bin in ~/.local/share/claude/versions/*; do
[ -x "$bin" ] && touch ~/.local/share/claude/dehat-stamps/$(basename "$bin").dehatted
done- Verify:
which claudeshould resolve to~/.pixi/bin/claude, not~/.local/bin/claude.
If a version update somehow shows the art, run rm ~/.local/share/claude/dehat-stamps/*.dehatted and relaunch. The wrapper will re-patch everything.
If the escape patterns change in a future version, update ART_CODEPOINTS in the python script.
Comments are posted through bimanual interface because gist.github does not yet connect natively to gh.
New installation script at
curl -fsSL 'https://gist.githubusercontent.com/yuxi-liu-wired/f4076b6f636d59a53b7e84d41d64397e/raw/d4f3e268bd5a6956b9e4ab63e34b8b132a98c0e9/erase_ascii_claude_code.sh' | bash
before:
after: