Skip to content

Instantly share code, notes, and snippets.

@yuxi-liu-wired
Last active March 10, 2026 05:45
Show Gist options
  • Select an option

  • Save yuxi-liu-wired/c46cc9e25779968ca3227e3d270aaa0e to your computer and use it in GitHub Desktop.

Select an option

Save yuxi-liu-wired/c46cc9e25779968ca3227e3d270aaa0e to your computer and use it in GitHub Desktop.
Erase the ASCII art in Claude Code
#!/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()
@yuxi-liu-wired
Copy link
Author

before:

image

after:

image

@yuxi-liu-wired
Copy link
Author

yuxi-liu-wired commented Mar 5, 2026

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 every claude invocation. 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 .dehatted file 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:

  1. Create the stamp directory:
mkdir -p ~/.local/share/claude/dehat-stamps
  1. Place the wrapper at ~/.pixi/bin/claude (or wherever is first in your PATH before ~/.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" "$@"
  1. Make it executable:
chmod +x ~/.pixi/bin/claude
  1. Place erase_ascii_claude_code.py at ~/.claude/erase_ascii_claude_code.py. The full script is in this gist.

  2. 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
  1. Verify: which claude should 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.

@yuxi-liu-wired
Copy link
Author

Comments are posted through bimanual interface because gist.github does not yet connect natively to gh.

@yuxi-liu-wired
Copy link
Author

New installation script at

curl -fsSL 'https://gist.githubusercontent.com/yuxi-liu-wired/f4076b6f636d59a53b7e84d41d64397e/raw/d4f3e268bd5a6956b9e4ab63e34b8b132a98c0e9/erase_ascii_claude_code.sh' | bash

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment