Last active
March 10, 2026 05:45
-
-
Save yuxi-liu-wired/c46cc9e25779968ca3227e3d270aaa0e to your computer and use it in GitHub Desktop.
Erase the ASCII art in Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
New installation script at