Skip to content

Instantly share code, notes, and snippets.

@Haleclipse
Last active January 26, 2026 17:34
Show Gist options
  • Select an option

  • Save Haleclipse/e60e52941ddb30061623e33c711eae54 to your computer and use it in GitHub Desktop.

Select an option

Save Haleclipse/e60e52941ddb30061623e33c711eae54 to your computer and use it in GitHub Desktop.
Claude Code ink2/render_v2 ANSI Ghost Characters Fix (Issue #19820)
<#
.SYNOPSIS
Claude Code ink2/render_v2 ANSI Ghost Characters Fix Script (Windows Version)
.DESCRIPTION
Fixes GitHub issue: anthropics/claude-code#19820 (duplicate of #17519)
THE BUG:
When statusLine contains ANSI escapes + Nerd Font icons under ink2/render_v2,
"ghost characters" appear - symbols leak into the message area and persist.
ROOT CAUSE:
Multiple places use String.length (UTF-16 code unit count) to approximate
terminal column width. For non-BMP characters (surrogate pairs), this
incorrectly adds extra width.
FIX POINTS:
1) mHA: z += H ? 2 : J.length -> z += H ? 2 : 1
2) RvA: w += O.fullWidth ? 2 : O.value.length -> w += O.fullWidth ? 2 : 1
3) yvA.get(): let f = V.fullWidth || V.value.length > 1 -> let f = V.fullWidth
.PARAMETER Check
Check if fix is needed without making changes
.PARAMETER Restore
Restore original file from backup
.PARAMETER Help
Show help information
.PARAMETER CliPath
Path to cli.js file (optional, auto-detect if not provided)
.EXAMPLE
.\apply-claude-code-ink2-ghost-chars-fix.ps1
Apply the fix (auto-detect cli.js location)
.EXAMPLE
.\apply-claude-code-ink2-ghost-chars-fix.ps1 -CliPath "C:\path\to\cli.js"
Apply fix to specific file
.EXAMPLE
.\apply-claude-code-ink2-ghost-chars-fix.ps1 -Check
Check if fix is needed
.EXAMPLE
.\apply-claude-code-ink2-ghost-chars-fix.ps1 -Restore
Restore from backup
.EXAMPLE
.\apply-claude-code-ink2-ghost-chars-fix.ps1 -Check -CliPath "C:\path\to\cli.js"
Check specific file
.NOTES
Requirements:
- Node.js (already installed if you have Claude Code)
- Internet connection (downloads acorn parser on first run)
Note: This patch will be overwritten when Claude Code updates.
Re-run this script after updates if ghost characters reappear.
#>
param(
[switch]$Check,
[switch]$Restore,
[switch]$Help,
[string]$CliPath
)
# Color output functions
function Write-Success { param($Message) Write-Host "[OK] " -ForegroundColor Green -NoNewline; Write-Host $Message }
function Write-Warning { param($Message) Write-Host "[!] " -ForegroundColor Yellow -NoNewline; Write-Host $Message }
function Write-FixError { param($Message) Write-Host "[X] " -ForegroundColor Red -NoNewline; Write-Host $Message }
function Write-Info { param($Message) Write-Host "[>] " -ForegroundColor Blue -NoNewline; Write-Host $Message }
# Main function to avoid exit closing terminal when run via iex
function Invoke-GhostCharsFix {
param(
[switch]$Check,
[switch]$Restore,
[switch]$Help,
[string]$CliPath
)
# Show help
if ($Help) {
Write-Host @"
Claude Code ink2/render_v2 ANSI Ghost Characters Fix Script
Usage:
.\apply-claude-code-ink2-ghost-chars-fix.ps1 [options]
Options:
-Check Check if fix is needed without making changes
-Restore Restore original file from backup
-CliPath Path to cli.js file (optional, auto-detect if not provided)
-Help Show this help message
Examples:
.\apply-claude-code-ink2-ghost-chars-fix.ps1
.\apply-claude-code-ink2-ghost-chars-fix.ps1 -Check
.\apply-claude-code-ink2-ghost-chars-fix.ps1 -CliPath "C:\path\to\cli.js"
"@
return 0
}
# Find Claude Code cli.js path
function Find-CliPath {
$locations = @(
(Join-Path $env:USERPROFILE ".claude\local\node_modules\@anthropic-ai\claude-code\cli.js"),
(Join-Path $env:APPDATA "npm\node_modules\@anthropic-ai\claude-code\cli.js"),
(Join-Path $env:ProgramFiles "nodejs\node_modules\@anthropic-ai\claude-code\cli.js"),
(Join-Path ${env:ProgramFiles(x86)} "nodejs\node_modules\@anthropic-ai\claude-code\cli.js")
)
# Try to get global path from npm
try {
$npmRoot = & npm root -g 2>$null
if ($npmRoot) {
$locations += Join-Path $npmRoot "@anthropic-ai\claude-code\cli.js"
}
} catch {}
foreach ($path in $locations) {
if (Test-Path $path) {
return $path
}
}
return $null
}
# Determine cliPath: use provided path or auto-detect
if ($CliPath) {
# User provided a path
if (Test-Path $CliPath) {
$cliPathResolved = $CliPath
Write-Info "Using specified cli.js: $cliPathResolved"
} else {
Write-FixError "Specified file not found: $CliPath"
return 1
}
} else {
# Auto-detect
$cliPathResolved = Find-CliPath
if (-not $cliPathResolved) {
Write-FixError "Claude Code cli.js not found"
Write-Host ""
Write-Host "Searched locations:"
Write-Host " ~\.claude\local\node_modules\@anthropic-ai\claude-code\cli.js"
Write-Host " %APPDATA%\npm\node_modules\@anthropic-ai\claude-code\cli.js"
Write-Host " %ProgramFiles%\nodejs\node_modules\@anthropic-ai\claude-code\cli.js"
Write-Host " `$(npm root -g)\@anthropic-ai\claude-code\cli.js"
Write-Host ""
Write-Host "Tip: You can specify the path directly:"
Write-Host " .\apply-claude-code-ink2-ghost-chars-fix.ps1 -CliPath 'C:\path\to\cli.js'"
return 1
}
Write-Info "Found Claude Code: $cliPathResolved"
}
# Use resolved path
$cliPath = $cliPathResolved
# Restore backup
if ($Restore) {
$backups = Get-ChildItem -Path (Split-Path $cliPath) -Filter "cli.js.backup-ghost-*" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending
if ($backups.Count -gt 0) {
$latestBackup = $backups[0].FullName
Copy-Item $latestBackup $cliPath -Force
Write-Success "Restored from backup: $latestBackup"
return 0
} else {
Write-FixError "No backup file found (cli.js.backup-ghost-*)"
return 1
}
}
Write-Host ""
# Download acorn parser if needed
$acornPath = Join-Path $env:TEMP "acorn-claude-fix.js"
if (-not (Test-Path $acornPath)) {
Write-Info "Downloading acorn parser..."
try {
Invoke-WebRequest -Uri "https://unpkg.com/acorn@8.14.0/dist/acorn.js" -OutFile $acornPath -UseBasicParsing
} catch {
Write-FixError "Failed to download acorn parser"
return 1
}
}
# Create patch script
$patchScript = @'
const fs = require('fs');
const acornPath = process.argv[2];
const acorn = require(acornPath);
const cliPath = process.argv[3];
const checkOnly = process.argv[4] === '--check';
let code = fs.readFileSync(cliPath, 'utf-8');
// Preserve shebang
let shebang = '';
if (code.startsWith('#!')) {
const idx = code.indexOf('\n');
shebang = code.slice(0, idx + 1);
code = code.slice(idx + 1);
}
// Track fix status
let fixes = {
mHA: { found: false, patched: false },
RvA: { found: false, patched: false },
yvA: { found: false, patched: false }
};
// Parse AST
let ast;
try {
ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: 'module' });
} catch (e) {
console.error('PARSE_ERROR:' + e.message);
process.exit(1);
}
// AST helper functions
function findNodes(node, predicate, results = []) {
if (!node || typeof node !== 'object') return results;
if (predicate(node)) results.push(node);
for (const key in node) {
if (node[key] && typeof node[key] === 'object') {
if (Array.isArray(node[key])) {
node[key].forEach(child => findNodes(child, predicate, results));
} else {
findNodes(node[key], predicate, results);
}
}
}
return results;
}
// Get source code snippet
const src = (node) => code.slice(node.start, node.end);
// ============================================================
// Fix 1: mHA function - z += H ? 2 : J.length -> z += H ? 2 : 1
// AST pattern: SequenceExpression containing:
// - AssignmentExpression: Y += J.length
// - AssignmentExpression: z += H ? 2 : J.length (ConditionalExpression)
// Key: same variable J appears in both .length accesses
// ============================================================
const allFunctions = findNodes(ast, n =>
n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression'
);
for (const fn of allFunctions) {
const fnSrc = src(fn);
// Must contain String.fromCodePoint to be the target function
if (!fnSrc.includes('String.fromCodePoint')) continue;
// Find SequenceExpressions in this function
const seqExprs = findNodes(fn, n => n.type === 'SequenceExpression');
for (const seq of seqExprs) {
const exprs = seq.expressions;
if (exprs.length < 2) continue;
// Look for pattern: X+=Y.length, Z+=W?2:Y.length
for (let i = 0; i < exprs.length - 1; i++) {
const first = exprs[i];
const second = exprs[i + 1];
// First must be: var += var.length
if (first.type !== 'AssignmentExpression' || first.operator !== '+=') continue;
if (first.right.type !== 'MemberExpression') continue;
if (first.right.property.type !== 'Identifier' || first.right.property.name !== 'length') continue;
const charVar = first.right.object.name; // e.g., 'J'
if (!charVar) continue;
// Second must be: var += cond ? 2 : charVar.length
if (second.type !== 'AssignmentExpression' || second.operator !== '+=') continue;
if (second.right.type !== 'ConditionalExpression') continue;
const cond = second.right;
// consequent should be literal 2
if (cond.consequent.type !== 'Literal' || cond.consequent.value !== 2) continue;
// alternate should be charVar.length
if (cond.alternate.type !== 'MemberExpression') continue;
if (cond.alternate.property.name !== 'length') continue;
if (cond.alternate.object.name !== charVar) continue;
// Found the pattern!
fixes.mHA.found = true;
fixes.mHA.node = second; // Store for precise replacement
console.log('FOUND:mHA pattern -> ' + src(second));
break;
}
if (fixes.mHA.found) break;
}
if (fixes.mHA.found) break;
}
// ============================================================
// Fix 2: RvA function - w += O.fullWidth ? 2 : O.value.length -> w += O.fullWidth ? 2 : 1
// AST pattern: AssignmentExpression where right is ConditionalExpression
// - test: obj.fullWidth
// - consequent: 2
// - alternate: obj.value.length (same obj)
// ============================================================
const assignExprs = findNodes(ast, n =>
n.type === 'AssignmentExpression' && n.operator === '+='
);
for (const assign of assignExprs) {
if (assign.right.type !== 'ConditionalExpression') continue;
const cond = assign.right;
// test should be obj.fullWidth
if (cond.test.type !== 'MemberExpression') continue;
if (cond.test.property.name !== 'fullWidth') continue;
const objName = cond.test.object.name;
if (!objName) continue;
// consequent should be 2
if (cond.consequent.type !== 'Literal' || cond.consequent.value !== 2) continue;
// alternate should be obj.value.length (same obj)
if (cond.alternate.type !== 'MemberExpression') continue;
if (cond.alternate.property.name !== 'length') continue;
if (cond.alternate.object.type !== 'MemberExpression') continue;
if (cond.alternate.object.property.name !== 'value') continue;
if (cond.alternate.object.object.name !== objName) continue;
// Found the pattern!
fixes.RvA.found = true;
fixes.RvA.node = assign;
console.log('FOUND:RvA pattern -> ' + src(assign));
break;
}
// ============================================================
// Fix 3: yvA.get() - let f = V.fullWidth || V.value.length > 1 -> let f = V.fullWidth
// AST pattern: VariableDeclarator where init is LogicalExpression (||)
// - left: obj.fullWidth
// - right: BinaryExpression (obj.value.length > 1)
// ============================================================
const varDeclarators = findNodes(ast, n => n.type === 'VariableDeclarator');
for (const decl of varDeclarators) {
if (!decl.init || decl.init.type !== 'LogicalExpression') continue;
if (decl.init.operator !== '||') continue;
const logic = decl.init;
// left should be obj.fullWidth
if (logic.left.type !== 'MemberExpression') continue;
if (logic.left.property.name !== 'fullWidth') continue;
const objName = logic.left.object.name;
if (!objName) continue;
// right should be obj.value.length > 1
if (logic.right.type !== 'BinaryExpression') continue;
if (logic.right.operator !== '>') continue;
if (logic.right.right.type !== 'Literal' || logic.right.right.value !== 1) continue;
// right.left should be obj.value.length
const lenExpr = logic.right.left;
if (lenExpr.type !== 'MemberExpression') continue;
if (lenExpr.property.name !== 'length') continue;
if (lenExpr.object.type !== 'MemberExpression') continue;
if (lenExpr.object.property.name !== 'value') continue;
if (lenExpr.object.object.name !== objName) continue;
// Found the pattern!
fixes.yvA.found = true;
fixes.yvA.node = decl;
console.log('FOUND:yvA pattern -> ' + src(decl));
break;
}
// Check if none found (may already be patched)
if (!fixes.mHA.found && !fixes.RvA.found && !fixes.yvA.found) {
// Verify if code is already in patched form
const patchedMHA = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\+=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\?\s*2\s*:\s*1(?![a-zA-Z0-9_$])/.test(code);
const patchedRvA = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\+=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\.fullWidth\s*\?\s*2\s*:\s*1(?![a-zA-Z0-9_$])/.test(code);
if (patchedMHA || patchedRvA) {
console.log('ALREADY_PATCHED');
process.exit(2);
}
console.error('NOT_FOUND:Unable to locate any code patterns requiring fix');
process.exit(1);
}
if (checkOnly) {
console.log('NEEDS_PATCH');
let count = 0;
if (fixes.mHA.found) count++;
if (fixes.RvA.found) count++;
if (fixes.yvA.found) count++;
console.log('PATCH_COUNT:' + count);
process.exit(1);
}
// Apply fixes
let newCode = code;
// Helper: replace code at specific position
function replaceAt(str, start, end, replacement) {
return str.slice(0, start) + replacement + str.slice(end);
}
// Collect all replacements (must apply from end to start to preserve positions)
let replacements = [];
// Fix 1: mHA - z += H ? 2 : J.length -> z += H ? 2 : 1
if (fixes.mHA.found && fixes.mHA.node) {
const node = fixes.mHA.node;
const cond = node.right; // ConditionalExpression
// Replace only the alternate part (J.length -> 1)
replacements.push({
start: cond.alternate.start,
end: cond.alternate.end,
replacement: '1',
name: 'mHA'
});
fixes.mHA.patched = true;
console.log('PATCH:mHA - Fixed width accumulation: var.length -> 1');
}
// Fix 2: RvA - w += O.fullWidth ? 2 : O.value.length -> w += O.fullWidth ? 2 : 1
if (fixes.RvA.found && fixes.RvA.node) {
const node = fixes.RvA.node;
const cond = node.right; // ConditionalExpression
// Replace only the alternate part (O.value.length -> 1)
replacements.push({
start: cond.alternate.start,
end: cond.alternate.end,
replacement: '1',
name: 'RvA'
});
fixes.RvA.patched = true;
console.log('PATCH:RvA - Fixed clip width counting: .value.length -> 1');
}
// Fix 3: yvA - let f = V.fullWidth || V.value.length > 1 -> let f = V.fullWidth
if (fixes.yvA.found && fixes.yvA.node) {
const node = fixes.yvA.node;
const logic = node.init; // LogicalExpression
// Replace the entire init with just the left part (V.fullWidth)
replacements.push({
start: logic.start,
end: logic.end,
replacement: src(logic.left),
name: 'yvA'
});
fixes.yvA.patched = true;
console.log('PATCH:yvA - Fixed wide char detection: removed || .value.length > 1');
}
// Apply replacements from end to start to preserve positions
replacements.sort((a, b) => b.start - a.start);
for (const r of replacements) {
newCode = replaceAt(newCode, r.start, r.end, r.replacement);
}
// Verify fixes applied
let patchedCount = 0;
if (fixes.mHA.patched) patchedCount++;
if (fixes.RvA.patched) patchedCount++;
if (fixes.yvA.patched) patchedCount++;
if (patchedCount === 0) {
console.error('VERIFY_FAILED:No fixes were applied');
process.exit(1);
}
// Backup original file
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupPath = cliPath + '.backup-ghost-' + timestamp;
fs.copyFileSync(cliPath, backupPath);
console.log('BACKUP:' + backupPath);
// Write patched file
fs.writeFileSync(cliPath, shebang + newCode);
console.log('SUCCESS:' + patchedCount);
'@
$tempPatchScript = Join-Path $env:TEMP "claude-ghost-patch-$PID.js"
$patchScript | Out-File -FilePath $tempPatchScript -Encoding UTF8
# Run patch script
$checkArg = if ($Check) { "--check" } else { "" }
$output = & node $tempPatchScript $acornPath $cliPath $checkArg 2>&1
$scriptExitCode = $LASTEXITCODE
# Cleanup temp script
Remove-Item $tempPatchScript -ErrorAction SilentlyContinue
# Process output
foreach ($line in $output) {
switch -Regex ($line) {
"^ALREADY_PATCHED" { Write-Success "Already patched"; return 0 }
"^PARSE_ERROR:(.+)" { Write-FixError "Failed to parse cli.js: $($Matches[1])"; return 1 }
"^NOT_FOUND:(.+)" { Write-FixError "Target code not found: $($Matches[1])"; return 1 }
"^FOUND:(.+)" { Write-Info "Found: $($Matches[1])" }
"^PATCH:(.+)" { Write-Info "Patch: $($Matches[1])" }
"^NEEDS_PATCH" {
Write-Host ""
Write-Warning "Patch needed - run without -Check to apply"
}
"^PATCH_COUNT:(.+)" {
Write-Info "Need to patch $($Matches[1]) location(s)"
return 1
}
"^BACKUP:(.+)" { Write-Host ""; Write-Host "Backup: $($Matches[1])" }
"^SUCCESS:(.+)" {
Write-Host ""
Write-Success "Fix applied successfully! Patched $($Matches[1]) location(s)"
Write-Host ""
Write-Warning "Restart Claude Code for changes to take effect"
}
"^VERIFY_FAILED:(.+)" { Write-FixError "Verification failed: $($Matches[1])"; return 1 }
}
}
return $scriptExitCode
}
# Invoke main function with passed parameters
Invoke-GhostCharsFix -Check:$Check -Restore:$Restore -Help:$Help -CliPath $CliPath
#!/bin/bash
#
# Claude Code ink2/render_v2 ANSI Ghost Characters Fix Script
#
# Fixes GitHub issue: anthropics/claude-code#19820 (duplicate of #17519)
#
# THE BUG:
# When statusLine contains ANSI escapes + Nerd Font icons under ink2/render_v2,
# "ghost characters" appear - symbols leak into the message area and persist.
#
# ROOT CAUSE:
# Multiple places use String.length (UTF-16 code unit count) to approximate
# terminal column width. For non-BMP characters (surrogate pairs), this
# incorrectly adds extra width.
#
# FIX POINTS:
# 1) mHA: z += H ? 2 : J.length → z += H ? 2 : 1
# 2) RvA: w += O.fullWidth ? 2 : O.value.length → w += O.fullWidth ? 2 : 1
# 3) yvA.get(): let f = V.fullWidth || V.value.length > 1 → let f = V.fullWidth
#
# Usage:
# ./apply-claude-code-ink2-ghost-chars-fix.sh # Apply fix (auto-detect)
# ./apply-claude-code-ink2-ghost-chars-fix.sh /path/to/cli.js # Apply fix to specific file
# ./apply-claude-code-ink2-ghost-chars-fix.sh --check # Check only
# ./apply-claude-code-ink2-ghost-chars-fix.sh --restore # Restore backup
#
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
success() { echo -e "${GREEN}[OK]${NC} $1"; }
warning() { echo -e "${YELLOW}[!]${NC} $1"; }
error() { echo -e "${RED}[X]${NC} $1"; }
info() { echo -e "${BLUE}[>]${NC} $1"; }
CHECK_ONLY=false
RESTORE=false
CLI_PATH_ARG=""
while [[ $# -gt 0 ]]; do
case $1 in
--check|-c) CHECK_ONLY=true; shift ;;
--restore|-r) RESTORE=true; shift ;;
--help|-h)
echo "Usage: $0 [options] [cli.js path]"
echo ""
echo "Fix ink2/render_v2 statusLine ANSI + Nerd Font ghost characters issue"
echo ""
echo "Arguments:"
echo " cli.js path Path to cli.js file (optional, auto-detect if not provided)"
echo ""
echo "Options:"
echo " --check, -c Check if fix is needed without making changes"
echo " --restore, -r Restore original file from backup"
echo " --help, -h Show help information"
echo ""
echo "Examples:"
echo " $0 # Auto-detect and apply fix"
echo " $0 /path/to/cli.js # Apply fix to specific file"
echo " $0 --check /path/to/cli.js # Check specific file"
echo " $0 /path/to/cli.js --check # Same as above"
exit 0
;;
-*)
error "Unknown option: $1"
exit 1
;;
*)
# Positional argument: cli.js path
if [[ -z "$CLI_PATH_ARG" ]]; then
CLI_PATH_ARG="$1"
else
error "Unexpected argument: $1"
exit 1
fi
shift
;;
esac
done
find_cli_path() {
local locations=(
"$HOME/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js"
"/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js"
"/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"
)
if command -v npm &> /dev/null; then
local npm_root
npm_root=$(npm root -g 2>/dev/null || true)
if [[ -n "$npm_root" ]]; then
locations+=("$npm_root/@anthropic-ai/claude-code/cli.js")
fi
fi
for path in "${locations[@]}"; do
if [[ -f "$path" ]]; then
echo "$path"
return 0
fi
done
return 1
}
# Determine CLI_PATH: use provided path or auto-detect
if [[ -n "$CLI_PATH_ARG" ]]; then
# User provided a path
if [[ -f "$CLI_PATH_ARG" ]]; then
CLI_PATH="$CLI_PATH_ARG"
info "Using specified cli.js: $CLI_PATH"
else
error "Specified file not found: $CLI_PATH_ARG"
exit 1
fi
else
# Auto-detect
CLI_PATH=$(find_cli_path) || {
error "Claude Code cli.js not found"
echo ""
echo "Searched locations:"
echo " ~/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js"
echo " /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js"
echo " \$(npm root -g)/@anthropic-ai/claude-code/cli.js"
echo ""
echo "Tip: You can specify the path directly:"
echo " $0 /path/to/cli.js"
exit 1
}
info "Found Claude Code: $CLI_PATH"
fi
CLI_DIR=$(dirname "$CLI_PATH")
if $RESTORE; then
LATEST_BACKUP=$(ls -t "$CLI_DIR"/cli.js.backup-ghost-* 2>/dev/null | head -1)
if [[ -n "$LATEST_BACKUP" ]]; then
cp "$LATEST_BACKUP" "$CLI_PATH"
success "Restored from backup: $LATEST_BACKUP"
exit 0
else
error "No backup file found (cli.js.backup-ghost-*)"
exit 1
fi
fi
echo ""
# Download acorn parser if needed
ACORN_PATH="/tmp/acorn-claude-fix.js"
if [[ ! -f "$ACORN_PATH" ]]; then
info "Downloading acorn parser..."
curl -sL "https://unpkg.com/acorn@8.14.0/dist/acorn.js" -o "$ACORN_PATH" || {
error "Failed to download acorn parser"
exit 1
}
fi
PATCH_SCRIPT=$(mktemp)
cat > "$PATCH_SCRIPT" << 'PATCH_EOF'
const fs = require('fs');
const acornPath = process.argv[2];
const acorn = require(acornPath);
const cliPath = process.argv[3];
const checkOnly = process.argv[4] === '--check';
let code = fs.readFileSync(cliPath, 'utf-8');
// Preserve shebang
let shebang = '';
if (code.startsWith('#!')) {
const idx = code.indexOf('\n');
shebang = code.slice(0, idx + 1);
code = code.slice(idx + 1);
}
// Track fix status
let fixes = {
mHA: { found: false, patched: false },
RvA: { found: false, patched: false },
yvA: { found: false, patched: false }
};
// Parse AST
let ast;
try {
ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: 'module' });
} catch (e) {
console.error('PARSE_ERROR:' + e.message);
process.exit(1);
}
// AST helper functions
function findNodes(node, predicate, results = []) {
if (!node || typeof node !== 'object') return results;
if (predicate(node)) results.push(node);
for (const key in node) {
if (node[key] && typeof node[key] === 'object') {
if (Array.isArray(node[key])) {
node[key].forEach(child => findNodes(child, predicate, results));
} else {
findNodes(node[key], predicate, results);
}
}
}
return results;
}
// Get source code snippet
const src = (node) => code.slice(node.start, node.end);
// ============================================================
// Fix 1: mHA function - z += H ? 2 : J.length → z += H ? 2 : 1
// AST pattern: SequenceExpression containing:
// - AssignmentExpression: Y += J.length
// - AssignmentExpression: z += H ? 2 : J.length (ConditionalExpression)
// Key: same variable J appears in both .length accesses
// ============================================================
const allFunctions = findNodes(ast, n =>
n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression'
);
for (const fn of allFunctions) {
const fnSrc = src(fn);
// Must contain String.fromCodePoint to be the target function
if (!fnSrc.includes('String.fromCodePoint')) continue;
// Find SequenceExpressions in this function
const seqExprs = findNodes(fn, n => n.type === 'SequenceExpression');
for (const seq of seqExprs) {
const exprs = seq.expressions;
if (exprs.length < 2) continue;
// Look for pattern: X+=Y.length, Z+=W?2:Y.length
for (let i = 0; i < exprs.length - 1; i++) {
const first = exprs[i];
const second = exprs[i + 1];
// First must be: var += var.length
if (first.type !== 'AssignmentExpression' || first.operator !== '+=') continue;
if (first.right.type !== 'MemberExpression') continue;
if (first.right.property.type !== 'Identifier' || first.right.property.name !== 'length') continue;
const charVar = first.right.object.name; // e.g., 'J'
if (!charVar) continue;
// Second must be: var += cond ? 2 : charVar.length
if (second.type !== 'AssignmentExpression' || second.operator !== '+=') continue;
if (second.right.type !== 'ConditionalExpression') continue;
const cond = second.right;
// consequent should be literal 2
if (cond.consequent.type !== 'Literal' || cond.consequent.value !== 2) continue;
// alternate should be charVar.length
if (cond.alternate.type !== 'MemberExpression') continue;
if (cond.alternate.property.name !== 'length') continue;
if (cond.alternate.object.name !== charVar) continue;
// Found the pattern!
fixes.mHA.found = true;
fixes.mHA.node = second; // Store for precise replacement
console.log('FOUND:mHA pattern -> ' + src(second));
break;
}
if (fixes.mHA.found) break;
}
if (fixes.mHA.found) break;
}
// ============================================================
// Fix 2: RvA function - w += O.fullWidth ? 2 : O.value.length → w += O.fullWidth ? 2 : 1
// AST pattern: AssignmentExpression where right is ConditionalExpression
// - test: obj.fullWidth
// - consequent: 2
// - alternate: obj.value.length (same obj)
// ============================================================
const assignExprs = findNodes(ast, n =>
n.type === 'AssignmentExpression' && n.operator === '+='
);
for (const assign of assignExprs) {
if (assign.right.type !== 'ConditionalExpression') continue;
const cond = assign.right;
// test should be obj.fullWidth
if (cond.test.type !== 'MemberExpression') continue;
if (cond.test.property.name !== 'fullWidth') continue;
const objName = cond.test.object.name;
if (!objName) continue;
// consequent should be 2
if (cond.consequent.type !== 'Literal' || cond.consequent.value !== 2) continue;
// alternate should be obj.value.length (same obj)
if (cond.alternate.type !== 'MemberExpression') continue;
if (cond.alternate.property.name !== 'length') continue;
if (cond.alternate.object.type !== 'MemberExpression') continue;
if (cond.alternate.object.property.name !== 'value') continue;
if (cond.alternate.object.object.name !== objName) continue;
// Found the pattern!
fixes.RvA.found = true;
fixes.RvA.node = assign;
console.log('FOUND:RvA pattern -> ' + src(assign));
break;
}
// ============================================================
// Fix 3: yvA.get() - let f = V.fullWidth || V.value.length > 1 → let f = V.fullWidth
// AST pattern: VariableDeclarator where init is LogicalExpression (||)
// - left: obj.fullWidth
// - right: BinaryExpression (obj.value.length > 1)
// ============================================================
const varDeclarators = findNodes(ast, n => n.type === 'VariableDeclarator');
for (const decl of varDeclarators) {
if (!decl.init || decl.init.type !== 'LogicalExpression') continue;
if (decl.init.operator !== '||') continue;
const logic = decl.init;
// left should be obj.fullWidth
if (logic.left.type !== 'MemberExpression') continue;
if (logic.left.property.name !== 'fullWidth') continue;
const objName = logic.left.object.name;
if (!objName) continue;
// right should be obj.value.length > 1
if (logic.right.type !== 'BinaryExpression') continue;
if (logic.right.operator !== '>') continue;
if (logic.right.right.type !== 'Literal' || logic.right.right.value !== 1) continue;
// right.left should be obj.value.length
const lenExpr = logic.right.left;
if (lenExpr.type !== 'MemberExpression') continue;
if (lenExpr.property.name !== 'length') continue;
if (lenExpr.object.type !== 'MemberExpression') continue;
if (lenExpr.object.property.name !== 'value') continue;
if (lenExpr.object.object.name !== objName) continue;
// Found the pattern!
fixes.yvA.found = true;
fixes.yvA.node = decl;
console.log('FOUND:yvA pattern -> ' + src(decl));
break;
}
// Check if none found (may already be patched)
if (!fixes.mHA.found && !fixes.RvA.found && !fixes.yvA.found) {
// Verify if code is already in patched form
const patchedMHA = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\+=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\?\s*2\s*:\s*1(?![a-zA-Z0-9_$])/.test(code);
const patchedRvA = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\+=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\.fullWidth\s*\?\s*2\s*:\s*1(?![a-zA-Z0-9_$])/.test(code);
if (patchedMHA || patchedRvA) {
console.log('ALREADY_PATCHED');
process.exit(2);
}
console.error('NOT_FOUND:Unable to locate any code patterns requiring fix');
process.exit(1);
}
if (checkOnly) {
console.log('NEEDS_PATCH');
let count = 0;
if (fixes.mHA.found) count++;
if (fixes.RvA.found) count++;
if (fixes.yvA.found) count++;
console.log('PATCH_COUNT:' + count);
process.exit(1);
}
// Apply fixes
let newCode = code;
// Helper: replace code at specific position
function replaceAt(str, start, end, replacement) {
return str.slice(0, start) + replacement + str.slice(end);
}
// Collect all replacements (must apply from end to start to preserve positions)
let replacements = [];
// Fix 1: mHA - z += H ? 2 : J.length → z += H ? 2 : 1
if (fixes.mHA.found && fixes.mHA.node) {
const node = fixes.mHA.node;
const cond = node.right; // ConditionalExpression
// Replace only the alternate part (J.length -> 1)
replacements.push({
start: cond.alternate.start,
end: cond.alternate.end,
replacement: '1',
name: 'mHA'
});
fixes.mHA.patched = true;
console.log('PATCH:mHA - Fixed width accumulation: var.length -> 1');
}
// Fix 2: RvA - w += O.fullWidth ? 2 : O.value.length → w += O.fullWidth ? 2 : 1
if (fixes.RvA.found && fixes.RvA.node) {
const node = fixes.RvA.node;
const cond = node.right; // ConditionalExpression
// Replace only the alternate part (O.value.length -> 1)
replacements.push({
start: cond.alternate.start,
end: cond.alternate.end,
replacement: '1',
name: 'RvA'
});
fixes.RvA.patched = true;
console.log('PATCH:RvA - Fixed clip width counting: .value.length -> 1');
}
// Fix 3: yvA - let f = V.fullWidth || V.value.length > 1 → let f = V.fullWidth
if (fixes.yvA.found && fixes.yvA.node) {
const node = fixes.yvA.node;
const logic = node.init; // LogicalExpression
// Replace the entire init with just the left part (V.fullWidth)
replacements.push({
start: logic.start,
end: logic.end,
replacement: src(logic.left),
name: 'yvA'
});
fixes.yvA.patched = true;
console.log('PATCH:yvA - Fixed wide char detection: removed || .value.length > 1');
}
// Apply replacements from end to start to preserve positions
replacements.sort((a, b) => b.start - a.start);
for (const r of replacements) {
newCode = replaceAt(newCode, r.start, r.end, r.replacement);
}
// Verify fixes applied
let patchedCount = 0;
if (fixes.mHA.patched) patchedCount++;
if (fixes.RvA.patched) patchedCount++;
if (fixes.yvA.patched) patchedCount++;
if (patchedCount === 0) {
console.error('VERIFY_FAILED:No fixes were applied');
process.exit(1);
}
// Backup original file
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupPath = cliPath + '.backup-ghost-' + timestamp;
fs.copyFileSync(cliPath, backupPath);
console.log('BACKUP:' + backupPath);
// Write patched file
fs.writeFileSync(cliPath, shebang + newCode);
console.log('SUCCESS:' + patchedCount);
PATCH_EOF
CHECK_ARG=""
if $CHECK_ONLY; then
CHECK_ARG="--check"
fi
OUTPUT=$(node "$PATCH_SCRIPT" "$ACORN_PATH" "$CLI_PATH" "$CHECK_ARG" 2>&1) || true
EXIT_CODE=$?
rm -f "$PATCH_SCRIPT"
while IFS= read -r line; do
case "$line" in
ALREADY_PATCHED)
success "Already patched"
exit 0
;;
PARSE_ERROR:*)
error "Failed to parse cli.js: ${line#PARSE_ERROR:}"
exit 1
;;
NOT_FOUND:*)
error "Target code not found: ${line#NOT_FOUND:}"
exit 1
;;
FOUND:*)
info "Found: ${line#FOUND:}"
;;
PATCH:*)
info "Patch: ${line#PATCH:}"
;;
NEEDS_PATCH)
echo ""
warning "Patch needed - run without --check to apply"
;;
PATCH_COUNT:*)
info "Need to patch ${line#PATCH_COUNT:} location(s)"
exit 1
;;
BACKUP:*)
echo ""
echo "Backup: ${line#BACKUP:}"
;;
SUCCESS:*)
echo ""
success "Fix applied successfully! Patched ${line#SUCCESS:} location(s)"
echo ""
warning "Restart Claude Code for changes to take effect"
;;
VERIFY_FAILED:*)
error "Verification failed: ${line#VERIFY_FAILED:}"
exit 1
;;
esac
done <<< "$OUTPUT"
exit $EXIT_CODE
@Haleclipse
Copy link
Author

Haleclipse commented Jan 23, 2026

Does this work with the native install of Claude?

No, we cannot handle "native" Claude Code built with Bun. Although we can extract cli.js from the bun build. it’s the same file as the one in the npm package. But it can’t be repackaged.

Even many versions ago, I’ve been recommending the use of the npm package over the Bun-built Claude Code.

Anthropic acquired bun. Bun is indeed more convenient than Node and better suited for building binaries.

However, based on the current situation with Claude Code, npm packages are more flexible, free, and convenient.

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