Replace tweakcc CLI with in-repo binary patcher (recent CC, macOS + Linux)#53
Open
whit3rabbit wants to merge 9 commits into
Open
Replace tweakcc CLI with in-repo binary patcher (recent CC, macOS + Linux)#53whit3rabbit wants to merge 9 commits into
whit3rabbit wants to merge 9 commits into
Conversation
Adds in-repo Bun bytecode/source extractor so cc-mirror can read native
Claude Code binaries without depending on tweakcc/node-lief, which breaks
when Bun changes its CompiledModuleGraphFile layout (36 -> 52 bytes
between Bun 1.3.5 and 1.3.13).
- src/core/bun-extract.ts: parseBunBinary, extractAll, replaceModule
(same-size in-place; flags signatureInvalidated on Mach-O builds with
LC_CODE_SIGNATURE).
- src/core/bun-extract/{macho,elf,pe}.ts: per-platform locators with the
ELF dataStart fix (trailerOffset - byteCount - OFFSETS_SIZE) and module
struct size auto-detection.
- src/cli/commands/unpack.ts + wiring: new cc-mirror unpack <variant|path>
command, replaces the AGENTS.md npx tweakcc unpack debug workflow.
- src/core/index.ts + src/cli/doctor.ts: doctor output now reports
platform, module count, entry path, and Bun version hint per variant.
- test/helpers/bun-fixture.ts + test/core/bun-extract*.test.ts: synthetic
Mach-O / ELF / PE fixtures cover both module-struct sizes, the ELF
dataStart regression, replace round-trip, traversal refusal, and code
signature detection.
Verified end-to-end on a 202MB macOS Claude Code 2.1.x build (11 modules
extracted, cli.js starts with the expected @Bun bytecode wrapper and
ends cleanly, .node addons round-trip as Mach-O dylibs).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Upstream tweakcc's node-lief writer is broken against Bun 1.3.13. On darwin it exits 0 but silently corrupts the binary (CJS-wrapper assertion at first launch); on linux it fails loudly with "could not extract JS". Both leave the user without a working claude. Wrap each runTweakcc invocation with a post-patch smoke test (<binary> --version, 5s). On tweakcc-fail or smoke-fail, restore the SHA256-verified pristine binary from the install cache, reset .claude.json themeId to the built-in dark, surface a rollback note, record meta.tweakRolledBack=true, and continue to WrapperStep so the variant ends up usable. Behavior is consistent across darwin / linux glibc / linux musl: a working binary every time, brand theme + prompt-pack overlays dropped only when rollback fires. The settings-only update path refuses to roll back (no reliable cache key without InstallNativeUpdateStep) and surfaces the original error unchanged. In-repo replacement for upstream tweakcc using replaceModule from src/core/bun-extract.ts is a separate effort (Phase 2). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 commit 1 of in-repo binary patcher work. Adds the byte-level engine cc-mirror needs to replace upstream tweakcc CLI: a Bun standalone-binary repack path that resizes the entry-module content, shifts module-table StringPointers across the resize delta, rebuilds the Offsets struct, and rewrites the per-platform container (Mach-O __BUN section_64 size + 8-byte u64 size header + LC_CODE_SIGNATURE strip via formal load-command walk; ELF appended-trailer truncation; PE .bun SizeOfRawData with a last-section guard that errors out rather than corrupt downstream sections). Vendoring lives at repos/tweakcc (gitignored, refresh via scripts/vendor-tweakcc.sh) with attribution in THIRD_PARTY_NOTICES.md pinning the upstream commit SHA we will port from in commit 2. Test fixture upgraded to write honest mach_header_64 ncmds/sizeofcmds when withCodeSignature is set so the formal LC walker can find the signature command (the existing heuristic scanner is unaffected).
Phase 2 commit 2. Adds two pure JS-string transforms that the orchestrator will run against the entry-module content unpacked by replaceEntryJs: - theme.ts ports tweakcc 303b756 src/patches/themes.ts (MIT) almost verbatim. Three regex anchors locate the theme switch statement, the theme options array, and the theme-name mapping object; we rewrite all three with our brand themes. Anchor failures throw ThemeAnchorNotFound so Phase 1 rollback can restore the pristine binary. Not idempotent on its own output, which is fine because we always patch from the cached pristine binary. - prompts.ts uses a different strategy than tweakcc's whole-prompt replacement. Each OverlayKey maps to a unique tail substring of the prompt's last literal piece (sourced from repos/tweakcc/data/prompts/prompts-2.1.98.json, MIT). We splice the overlay block (wrapped in <!-- cc-mirror:provider-overlay start/end --> markers) immediately after the tail anchor, escaping for the detected string delimiter (template literal vs single/double quote). Re-running replaces the existing block instead of duplicating, matching the old applyPromptPack semantics. Initial anchor coverage: the eight OverlayKeys today's prompt-pack actually patches successfully (webfetch, websearch, explore, planEnhanced, enterPlan, skill, conversationSummary, webfetchSummary). The other OverlayKeys silently no-op, matching pre-Phase-2 behaviour. Tests: 14 unit tests across both modules using hand-crafted JS fixtures that mirror Bun-compiled Claude Code's template-literal prompt shape.
Phase 2 commit 3. Wires the resize engine + theme + prompt patches into
a single applyPatches({ binaryPath, config, overlays }): PatchResult
entrypoint and adds the macOS codesign helper that pairs with the
Mach-O LC_CODE_SIGNATURE strip from commit 1.
applyPatches sequence: read binary -> parseBunBinary -> applyTheme
(throws ThemeAnchorNotFound on miss -> ok:false anchor-not-found) ->
applyPrompts (best-effort; missing anchors recorded, not fatal) ->
replaceEntryJs (catches PeNotLastSectionError -> resize-bound-exceeded)
-> writeFile -> chmod 0o755 (POSIX) -> tryAdhocSign (only when
signatureStripped on darwin; codesign-failed if codesign rejected the
input, codesignSkipped soft-warning if codesign isn't in PATH).
Failures return structured PatchResult instead of throwing, so the
upcoming BinaryPatcherStep can map them onto Phase 1 rollback.
tryAdhocSign: spawnSync('codesign', ['--force', '--sign', '-', path])
on darwin only; non-darwin returns no-codesign immediately. ENOENT on
the spawn maps to no-codesign (CI without Xcode CLT); non-zero exit
maps to failed with the trimmed stderr in `detail`.
Tests: end-to-end applyPatches on synthetic ELF/Mach-O/PE fixtures
exercising the happy path, anchor-not-found on a broken theme switch,
missing-prompt-key recording, and io-error on unreadable binaryPath.
codesign.test.ts covers the no-codesign branch (non-darwin) and the
failed branch (darwin: feed /etc/hosts to codesign, accept either
failed or no-codesign depending on whether Xcode CLT is installed).
Phase 2 commit 4 wires the in-repo patcher into the variant create + update
pipelines and removes every shell-out to tweakcc from cc-mirror.
New BinaryPatcherStep + BinaryPatcherUpdateStep load tweakcc/config.json +
provider overlays, call applyPatches, run a post-patch smokeTestBinary, and
on failure trigger the same Phase 1 rollback flow the old TweakccStep had:
restorePristineBinary -> reset .claude.json themeId to dark -> push rollback
note -> set state.tweakRolledBack = true.
Mach-O has its own contract: applyPatches uses replaceModule (same-size
only) to avoid Mach-O segment shifting, which means theme/prompt patches
that would grow the entry JS are SKIPPED (binary stays pristine, variant
remains functional). The skip surfaces as result.skippedReason and a clear
note in state.notes; no rollback fires. ELF and PE handle resize via
replaceEntryJs as before. CLAUDE.local.md tracks the macOS limitation as
Phase 2 follow-up work.
Slimmed src/core/tweakcc.ts to just the helpers we still need: smokeTest
Binary, formatRollbackNote, ensureTweakccConfig, TweakccPatchFailure.
Removed runTweakcc, runTweakccAsync, launchTweakccUi, getNpxCommand,
getTweakccFallbackNote, TweakccResult, the npx invocation builders, and
TWEAKCC_VERSION from constants.ts.
Removed TweakResult.tweakccSpec / fallbackFromTweakccSpec (npx-fallback
metadata only); updated tweakRolledBack JSDoc to describe the patcher
rather than tweakcc. Field name stays the same for variant.json
compatibility.
Removed src/core/errors.ts (formatTweakccFailure +
isTweakccNativeExtractionFailure parsed npx tweakcc stderr; dead now) and
src/cli/commands/tweak.ts + core.tweakVariant (the interactive customization
UI was launchTweakccUi-only; no in-repo equivalent exists yet).
Tests: 272 pass, 0 fail. Deleted obsolete tweakcc-{command,windows-async-exec,
windows-exec}.test.ts (stubbed npx) and create-with-tweakcc-stub.test.ts
(npx + real binary download). The 12 helper unit tests in
tweakcc-rollback.test.ts (smokeTestBinary, restorePristineBinary,
formatRollbackNote contract) are intact and still passing - the trailing
integration test that depended on npx PATH-stubbing was dropped; an
equivalent integration test against the new patcher is planned for commit 5.
Verified end-to-end on darwin-arm64: cc-mirror create --provider minimax
produces a launching variant with the expected "Mach-O patch skipped" note.
Phase 2 commit 5 closes the loop: - test/core/binary-patcher/integration.test.ts drives core.createVariantAsync against a real Claude Code binary downloaded from the install manifest. Network-gated (CC_MIRROR_NETWORK_TESTS=1). Asserts wrapper launches and that platform-specific outcomes are correct: linux ELF gets the rewritten theme bytes embedded; darwin Mach-O sees the documented "patch skipped" note while the pristine binary still launches. - AGENTS.md drops stale tweakcc references: Tweakcc step renamed to BinaryPatcher in the build-order list, prompt pack section describes the in-process splice, the unpack snippet uses cc-mirror unpack instead of npx tweakcc unpack, and the project tree gets a src/core/binary-patcher/ entry plus a more honest description of brands/. - README.md fixes commands that no longer exist (cc-mirror tweak), reframes brand-themes as cc-mirror's own patcher (with attribution to tweakcc upstream + macOS limitation note), updates --no-tweak / --verbose help text to match the slimmed CLI, points "Want to add a provider?" at AGENTS.md instead of the deleted TWEAKCC-GUIDE. - docs/architecture/overview.md updates the build/update-step diagrams to show BinaryPatcherStep / BinaryPatcherUpdateStep and drops the cli.js.backup directory entry (the in-repo patcher uses ~/.cc-mirror/.cache/claude-native/<ver>/<platform>/claude as its rollback source; per-variant cli.js.backup is gone). - docs/TWEAKCC-GUIDE.md deleted: it described tweakcc CLI integration that no longer exists. Tests: 272 pass, 0 fail, 3 skipped (the new network test plus the two darwin-specific codesign tests).
The synthetic ELF fixture in test/helpers/bun-fixture.ts is a skeleton with no section header table and no PT_LOAD program headers, so the unit tests passed end-to-end without exercising any of the metadata fixups a real Bun-compiled Claude Code binary needs after a resize. Verified against the real linux-x64 binary inside docker - every linux variant rolled back before this fix because the patched binary either silently fell back to plain Bun mode or SIGBUS'd / SIGILL'd on first launch. Four metadata fixups are now applied during repackElf: 1. ELF header e_shoff (+40, u64) - shifted by delta when past dataStart, so readers find the section header table at its new location. 2. ELF header e_phoff (+32, u64) - same rule, defensive. 3. .bun section header sh_size - walked by section name in the section header table; Bun's standalone runtime locates the embedded payload via this section, so its claimed size has to grow with the new bytes. 4. PT_LOAD program header for .bun: p_filesz AND p_memsz both bumped by delta. Real CC PT_LOAD has ~one 4 KB page of slack (filesz rounded up to alignment) so resizes smaller than that slack worked by accident; larger ones crashed because the kernel mmap stopped at the old p_filesz boundary and Bun's read past the end hit unmapped pages. Bonus: also rewrites the 8-byte u64 size prefix at the start of the .bun section payload (mirrors the Mach-O __BUN layout). The original ELF tail (section header table + string tables + any trailing padding) is now preserved byte-for-byte instead of being truncated at the trailer. Verification (post-fix): - linux-x64 glibc: cc-mirror create --provider zai --no-tweak omitted -> patched binary launches, --version reports "2.1.119 (Claude Code)", binary contains 3 cc-mirror:provider-overlay markers and the Z.ai Carbon brand theme name. - linux-x64-musl: identical happy path (Alpine + libc6-compat). - darwin-arm64: skip path unchanged; cc-mirror create --provider ollama --model-* qwen2.5:7b yields a working wrapper that successfully round-trips a /print query against a live local ollama backend.
When Mach-O patches would grow the __BUN section, BinaryPatcherStep no
longer just skips. Instead it extracts the embedded JS modules from the
SHA256-verified pristine cache, strips Bun's CJS wrapper, applies theme +
prompt overlays as plain string substitutions, installs the four runtime
deps Bun externalizes (ws, node-fetch, undici, yaml) into a per-variant
node_modules, and rewrites the wrapper to `exec node <entry>`. macOS
variants now have full feature parity with linux/windows.
Probe finding: Anthropic's bundled cli.js is typeof-Bun-friendly. 30+ of
36 Bun.* references are gated by `typeof Bun<"u"` ternaries with JS
fallbacks; the few unguarded ones (`Bun.spawn` x3, `Bun.Terminal` x3)
sit inside try/catch. Bash tool, MCP servers, and version detection all
gracefully fall through. No Bun shim shipped.
New files (src/core/binary-patcher/):
- strip-bun-wrapper.ts: removes Bun's `(function(...){<body>});` wrapper
so Node's CJS loader can drive the entry. 90-byte regex; idempotent.
- js-patch.ts: applyTheme + applyPrompts on extracted JS files (reuses
the existing string-based anchors).
- unpack-and-patch.ts: orchestrator + npm install + UnpackAndPatchError
failure type. Always wipes unpackedDir + re-extracts from cache because
applyTheme is non-idempotent (objArr regex matches pristine labels only).
State threading: BuildState + UpdateState gain wrapperRuntime + nodeEntryPath;
BuildPaths + UpdatePaths gain unpackedDir; VariantMeta persists all three so
update preserves the runtime choice. WrapperStep + WrapperUpdateStep read
state.wrapperRuntime; FinalizeStep + FinalizeUpdateStep persist.
Failure handling: orchestrator throws UnpackAndPatchError, caught by
BinaryPatcherStep + BinaryPatcherUpdateStep which run the existing Phase 1
rollback (restorePristineBinary + tweakRolledBack=true + force native runtime).
Tests: new strip-bun-wrapper.test.ts (6 cases) + js-patch.test.ts (5 cases).
update-rebuild.test.ts adapted for new UpdatePaths.unpackedDir field.
286 tests, 283 pass / 3 skipped / 0 fail. Typecheck clean.
Verified end-to-end on darwin-arm64 (CC 2.1.119):
- zai + minimax create cleanly via the new path
- ~/.local/bin/{zai,minimax} --version returns 2.1.119 (Claude Code)
- --print "say READY" round-trips through provider APIs
- Bash tool works (echo HELLO_FROM_BASH returned via tool use)
- Patched cli.js contains brand theme bytes + provider-overlay markers
New requirement: macOS variants need `node` in PATH. doctor command does
not currently check; follow-up.
Forward-compat tripwire: if a future CC version adds a `bun:` import
outside replaceable shims, runtime require() fails. Catch via
`grep -c "from ['\"]bun:" ~/.cc-mirror/<v>/unpacked/src/entrypoints/cli.js`
after upstream version bumps.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Replaces the
npx tweakccshell-out with an in-repo binary patcher built directly on top of cc-mirror's existing Bun-extract path. This restores theme + prompt-overlay customization on recent Claude Code releases (verified against 2.1.119) on macOS and Linux, which were silently rolling back to pristine binaries before.Why
tweakccCLI invocation has been failing against current CC for a while:node-liefMach-O write path can't grow the__BUNsection without rebuilding LINKEDIT, so patches landed corrupted and Phase 1 rollback fired on every variant create.Net effect: brand themes and per-provider prompt overlays were not actually being applied on any platform for recent CC, even though the build pipeline reported success.
What this PR does
New
src/core/binary-patcher/module owns the patch lifecycle end-to-end:.bunsection, with all the per-platform metadata fixups Bun's standalone runtime needs. ELF specifically:e_shoff/e_phoff, the.bunsh_size, and the PT_LOADp_filesz+p_memszcovering the section. PE:.bunSizeOfRawDatawith a last-section guard. Wrapper still execs the patched native binary.<variant>/unpacked/, strip Bun's CJS wrapper, apply theme + prompts as plain string substitutions, install the four runtime deps Bun externalizes (ws, node-fetch, undici, yaml), and rewrite the wrapper toexec node <entry>. Adds a runtime requirement:nodein PATH on macOS variants.npx tweakccis no longer invoked anywhere. Thecc-mirror tweakcommand and the interactive customization UI are removed (no in-repo equivalent yet). Thetweakcc/config.jsondirectory name is preserved on disk for backwards compatibility with existing variants.Theme regex anchors are ported almost verbatim from
tweakcc@303b756(src/patches/themes.ts, MIT). Prompt-overlay splicing is new. Upstream attribution and the vendored reference copy are tracked inTHIRD_PARTY_NOTICES.mdandrepos/tweakcc/(gitignored, refresh viascripts/vendor-tweakcc.sh).Type of Change
Minor on-disk shape change for new variants on macOS (adds
<variant>/unpacked/pluswrapperRuntime: 'node'invariant.json). Existing variants continue to work.Related Issues
None tracked upstream that I'm aware of. Happy to open one to capture the failure modes if useful.
How Has This Been Tested?
npm run check(typecheck + lint + test) — 286 tests, 283 pass / 3 skipped (network-gated + macOS codesign) / 0 failEnd-to-end verified against real Claude Code 2.1.119:
node:20-bookworm-slim,cc-mirror create --provider zai--versionreturns2.1.119 (Claude Code), patched binary contains the brand theme bytes + 3cc-mirror:provider-overlaymarkersnode:20-alpine+libc6-compatcc-mirror create --provider zai, then~/.local/bin/zai --print 'say READY'against the live Z.ai backendREADY. Bash tool round-trips (--print --allow-dangerously-skip-permissions "Run 'echo HELLO_FROM_BASH' via Bash tool"→HELLO_FROM_BASH). Same checks pass forminimax.cc-mirror create --provider ollama --model-* qwen2.5:7bagainst a local ollama backend--printquery.Network-gated integration test (
test/core/binary-patcher/integration.test.ts,CC_MIRROR_NETWORK_TESTS=1) drives the full create flow against a live download.Known Limitations / Follow-ups
Documented in detail in commit messages and
CLAUDE.local.md:node-lief-based path is the canonical reference for the per-LC fixups required.main,bash,mcpCli,mcpsearch,taskAgent,planReminder,planReminderSub,taskTool,exitPlan,conversationSummaryExtended) silently no-op. This is parity with the previous tweakcc-based behaviour: those keys were already silently no-opping becausePROMPT_PACK_TARGETSfilenames didn't match any tweakcc-extracted prompt id. Adding anchors is straightforward but out of scope for this PR.nodein PATH. Thedoctorcommand does not currently check for it. Worth a follow-up.Checklist
AGENTS.md,README.md,docs/architecture/overview.md,docs/RECONSTRUCTION-LEDGER.md.docs/TWEAKCC-GUIDE.mddeleted)Commit Layout
9 commits, kept separate intentionally so each architectural decision is easy to review and bisect:
1b76378integrate Bun standalone-binary extraction (base infra)d35ae0broll back to pristine binary when tweakcc patch fails (Phase 1 safety net, kept after tweakcc removal because the new patcher uses the same rollback contract)12fb3earesize-capable entry-module replacement + per-platform repackf85fed3port theme + prompt anchorsa2025fcorchestrator + ad-hoc codesign helper072f84dswap build steps off tweakcc CLIe2bc7benetwork-gated patcher integration test, refresh docs2910fa0fix ELF resize against real CC binaries (PT_LOAD + section header table fixups)c119f21macOS unpack-and-run-via-node fallbackHappy to squash if preferred.