Skip to content

Replace tweakcc CLI with in-repo binary patcher (recent CC, macOS + Linux)#53

Open
whit3rabbit wants to merge 9 commits into
numman-ali:mainfrom
whit3rabbit:feat/bun-extract
Open

Replace tweakcc CLI with in-repo binary patcher (recent CC, macOS + Linux)#53
whit3rabbit wants to merge 9 commits into
numman-ali:mainfrom
whit3rabbit:feat/bun-extract

Conversation

@whit3rabbit
Copy link
Copy Markdown

Description

Replaces the npx tweakcc shell-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

tweakcc CLI invocation has been failing against current CC for a while:

  • darwin: tweakcc's node-lief Mach-O write path can't grow the __BUN section without rebuilding LINKEDIT, so patches landed corrupted and Phase 1 rollback fired on every variant create.
  • linux: similar resize-without-fixups failure mode (silent SIGBUS/SIGILL on first launch unless the resize happened to fit inside the existing PT_LOAD slack).

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:

  1. Linux (ELF) + Windows (PE): byte-level resize of the entry-module content inside the embedded .bun section, with all the per-platform metadata fixups Bun's standalone runtime needs. ELF specifically: e_shoff / e_phoff, the .bun sh_size, and the PT_LOAD p_filesz + p_memsz covering the section. PE: .bun SizeOfRawData with a last-section guard. Wrapper still execs the patched native binary.
  2. macOS (Mach-O): Mach-O segment shifting is a substantial port (LC_SEGMENT_64 + LC_SYMTAB + LC_DYSYMTAB + LC_DYLD_INFO_ONLY + LC_LINKEDIT family + ad-hoc resign), so this PR ships a different approach: extract the JS modules from the SHA256-verified pristine binary into <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 to exec node <entry>. Adds a runtime requirement: node in PATH on macOS variants.
  3. Phase 1 rollback contract preserved. Any patch failure (missing anchor, resize-bound exceeded, npm install error, smoke-test fail) restores from the SHA256-verified pristine cache and continues to a working variant with stock CC behaviour.

npx tweakcc is no longer invoked anywhere. The cc-mirror tweak command and the interactive customization UI are removed (no in-repo equivalent yet). The tweakcc/config.json directory 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 in THIRD_PARTY_NOTICES.md and repos/tweakcc/ (gitignored, refresh via scripts/vendor-tweakcc.sh).

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change
  • Documentation update

Minor on-disk shape change for new variants on macOS (adds <variant>/unpacked/ plus wrapperRuntime: 'node' in variant.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?

  • Ran npm run check (typecheck + lint + test) — 286 tests, 283 pass / 3 skipped (network-gated + macOS codesign) / 0 fail
  • Tested CLI commands

End-to-end verified against real Claude Code 2.1.119:

Platform How Outcome
linux-x64 glibc docker node:20-bookworm-slim, cc-mirror create --provider zai Wrapper launches, --version returns 2.1.119 (Claude Code), patched binary contains the brand theme bytes + 3 cc-mirror:provider-overlay markers
linux-x64 musl docker node:20-alpine + libc6-compat Same as glibc
darwin-arm64 cc-mirror create --provider zai, then ~/.local/bin/zai --print 'say READY' against the live Z.ai backend Returns READY. Bash tool round-trips (--print --allow-dangerously-skip-permissions "Run 'echo HELLO_FROM_BASH' via Bash tool"HELLO_FROM_BASH). Same checks pass for minimax.
darwin-arm64 cc-mirror create --provider ollama --model-* qwen2.5:7b against a local ollama backend Wrapper round-trips a --print query.

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:

  1. Mach-O byte-level resize is intentionally a no-op. macOS uses the unpack-and-run-via-node path instead. A proper Mach-O segment shifter is the right long-term fix and is scoped as a follow-up. Tweakcc's node-lief-based path is the canonical reference for the per-LC fixups required.
  2. Prompt-overlay anchor coverage: 8 of 19 OverlayKeys. The other 11 (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 because PROMPT_PACK_TARGETS filenames didn't match any tweakcc-extracted prompt id. Adding anchors is straightforward but out of scope for this PR.
  3. macOS variants now require node in PATH. The doctor command does not currently check for it. Worth a follow-up.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code where necessary (the "why" is in commit messages + JSDoc on the per-platform resize code)
  • I have updated the documentation if needed (AGENTS.md, README.md, docs/architecture/overview.md, docs/RECONSTRUCTION-LEDGER.md. docs/TWEAKCC-GUIDE.md deleted)
  • My changes generate no new warnings
  • I have added tests (theme + prompt anchor unit tests, applyPatches end-to-end on synthetic ELF/Mach-O/PE fixtures, codesign helper, strip-bun-wrapper + js-patch unit tests, network-gated integration)
  • New and existing tests pass locally

Commit Layout

9 commits, kept separate intentionally so each architectural decision is easy to review and bisect:

  1. 1b76378 integrate Bun standalone-binary extraction (base infra)
  2. d35ae0b roll 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)
  3. 12fb3ea resize-capable entry-module replacement + per-platform repack
  4. f85fed3 port theme + prompt anchors
  5. a2025fc orchestrator + ad-hoc codesign helper
  6. 072f84d swap build steps off tweakcc CLI
  7. e2bc7be network-gated patcher integration test, refresh docs
  8. 2910fa0 fix ELF resize against real CC binaries (PT_LOAD + section header table fixups)
  9. c119f21 macOS unpack-and-run-via-node fallback

Happy to squash if preferred.

whit3rabbit and others added 9 commits April 25, 2026 15:25
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant