patch-cc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
patch_cc/bun/macho.py ADDED
@@ -0,0 +1,124 @@
1
+ """Mach-O ``__BUN,__bun`` handling for macOS, via LIEF.
2
+
3
+ Unlike ELF, we use LIEF to write here. Mach-O segment growth means shifting
4
+ ``__LINKEDIT`` and fixing every load command that references it -- LIEF does
5
+ that correctly, and it has no pathological relocation behaviour on Mach-O
6
+ (growth is page-aligned and bounded, so there is no size blow-up).
7
+
8
+ Any edit invalidates the code signature, and on Apple Silicon an unsigned or
9
+ stale-signature binary is killed on launch rather than merely warned about. So
10
+ the signature is removed before writing and an ad-hoc one is applied after.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+
18
+ from .errors import BunError
19
+
20
+ SEGMENT = "__BUN"
21
+ SECTION = "__bun"
22
+
23
+
24
+ class MachOError(BunError):
25
+ """The Mach-O could not be read or rewritten."""
26
+
27
+
28
+ def _lief():
29
+ try:
30
+ import lief # noqa: PLC0415
31
+ except ImportError as exc: # pragma: no cover - platform dependent
32
+ raise MachOError(
33
+ "LIEF is required to patch macOS binaries. Install it with "
34
+ "`uv tool install patch-cc` or `pip install lief`."
35
+ ) from exc
36
+ lief.logging.disable()
37
+ return lief
38
+
39
+
40
+ def _binary(path: str):
41
+ lief = _lief()
42
+ fat = lief.MachO.parse(path)
43
+ if fat is None:
44
+ raise MachOError(f"not a Mach-O binary: {path}")
45
+ # `parse` returns a FatBinary; Claude ships thin per-arch binaries.
46
+ binary = fat.at(0) if hasattr(fat, "at") else fat
47
+ if binary is None:
48
+ raise MachOError(f"no Mach-O slice found in {path}")
49
+ return lief, binary
50
+
51
+
52
+ def read_section(path: str) -> bytes:
53
+ _lief_mod, binary = _binary(path)
54
+ segment = binary.get_segment(SEGMENT)
55
+ if segment is None:
56
+ raise MachOError(f"no {SEGMENT} segment -- not a Bun standalone binary")
57
+ section = segment.get_section(SECTION)
58
+ if section is None:
59
+ raise MachOError(f"no {SEGMENT},{SECTION} section")
60
+ return bytes(section.content)
61
+
62
+
63
+ def _page_size(lief, binary) -> int:
64
+ try:
65
+ return (
66
+ 16384
67
+ if binary.header.cpu_type == lief.MachO.Header.CPU_TYPE.ARM64
68
+ else 4096
69
+ )
70
+ except AttributeError: # pragma: no cover - LIEF API drift
71
+ return 16384
72
+
73
+
74
+ def write_section(path: str, payload: bytes, out_path: str | None = None) -> None:
75
+ """Replace ``__BUN,__bun`` with ``payload`` and re-sign ad-hoc."""
76
+ out_path = out_path or path
77
+ lief, binary = _binary(path)
78
+
79
+ if binary.has_code_signature:
80
+ binary.remove_signature()
81
+
82
+ segment = binary.get_segment(SEGMENT)
83
+ if segment is None:
84
+ raise MachOError(f"no {SEGMENT} segment")
85
+ section = segment.get_section(SECTION)
86
+ if section is None:
87
+ raise MachOError(f"no {SEGMENT},{SECTION} section")
88
+
89
+ grow = len(payload) - int(section.size)
90
+ if grow > 0:
91
+ page = _page_size(lief, binary)
92
+ aligned = -(-grow // page) * page
93
+ if not binary.extend_segment(segment, aligned):
94
+ raise MachOError(f"failed to extend {SEGMENT} by {aligned} bytes")
95
+
96
+ section.content = list(payload)
97
+ section.size = len(payload)
98
+ binary.write(out_path)
99
+ codesign(out_path)
100
+
101
+
102
+ def codesign(path: str) -> None:
103
+ """Apply an ad-hoc signature. Required for the binary to run on arm64."""
104
+ if not shutil.which("codesign"): # pragma: no cover - macOS only
105
+ raise MachOError("`codesign` not found; cannot sign the patched binary")
106
+ result = subprocess.run(
107
+ ["codesign", "--sign", "-", "--force", path],
108
+ capture_output=True,
109
+ text=True,
110
+ )
111
+ if result.returncode != 0: # pragma: no cover - macOS only
112
+ raise MachOError(f"codesign failed: {result.stderr.strip()}")
113
+
114
+
115
+ def verify_signature(path: str) -> bool: # pragma: no cover - macOS only
116
+ if not shutil.which("codesign"):
117
+ return False
118
+ return (
119
+ subprocess.run(
120
+ ["codesign", "--verify", "--verbose=2", path],
121
+ capture_output=True,
122
+ ).returncode
123
+ == 0
124
+ )
patch_cc/cache.py ADDED
@@ -0,0 +1,86 @@
1
+ """Remembered interactive selection.
2
+
3
+ The menu's only memory: the patches and customisations picked last time, so the
4
+ next interactive run comes up pre-filled even after a Claude auto-update wiped
5
+ the patched binary (and its manifest) away. It is never read by the ``apply``
6
+ args path and never applies anything on its own -- deleting the file simply
7
+ resets the menu to defaults.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from .patches import DEFAULT_BRAND, DEFAULT_SUFFIX, Options, default_ids, ids
18
+
19
+
20
+ def cache_path() -> Path:
21
+ base = os.environ.get("XDG_CACHE_HOME")
22
+ root = Path(base) if base else Path.home() / ".cache"
23
+ return root / "patch-cc" / "selection.json"
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class Selection:
28
+ patches: list[str] = field(default_factory=default_ids)
29
+ options: Options = field(default_factory=Options)
30
+
31
+
32
+ def load() -> Selection:
33
+ """The last selection, or a fresh default set when none is cached or the
34
+ file is unreadable -- the menu always gets a usable, pre-checkable set.
35
+
36
+ Only shapes are validated here (strings in the right places); whether an
37
+ agent or model still exists is the menu's question, answered against the
38
+ binary it is about to patch.
39
+ """
40
+ try:
41
+ data = json.loads(cache_path().read_text("utf8"))
42
+ known = set(ids())
43
+ brand = data.get("brand", DEFAULT_BRAND)
44
+ suffix = data.get("suffix", DEFAULT_SUFFIX)
45
+ models = data.get("subagent_models", {})
46
+ return Selection(
47
+ patches=[p for p in data.get("patches", default_ids()) if p in known],
48
+ options=Options(
49
+ brand=brand if isinstance(brand, str) and brand else DEFAULT_BRAND,
50
+ version_suffix=suffix
51
+ if isinstance(suffix, str) and suffix
52
+ else DEFAULT_SUFFIX,
53
+ subagent_models={
54
+ a: m
55
+ for a, m in models.items()
56
+ if isinstance(a, str) and isinstance(m, str)
57
+ },
58
+ ),
59
+ )
60
+ except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError):
61
+ return Selection()
62
+
63
+
64
+ def save(selection: Selection) -> None:
65
+ """Best-effort: a cache that cannot be written just means no pre-fill next
66
+ run -- it must never break the patch it was only trying to remember."""
67
+ try:
68
+ path = cache_path()
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ tmp = path.with_suffix(".tmp")
71
+ tmp.write_text(
72
+ json.dumps(
73
+ {
74
+ "patches": selection.patches,
75
+ "brand": selection.options.brand,
76
+ "suffix": selection.options.version_suffix,
77
+ "subagent_models": selection.options.subagent_models,
78
+ },
79
+ indent=2,
80
+ )
81
+ + "\n",
82
+ "utf8",
83
+ )
84
+ tmp.replace(path)
85
+ except OSError:
86
+ pass
patch_cc/cli.py ADDED
@@ -0,0 +1,399 @@
1
+ """Command-line entry point.
2
+
3
+ Bare ``patch-cc`` opens the interactive menu. Every action is also a
4
+ subcommand so nothing needs the TUI:
5
+
6
+ patch-cc apply [PATCH ...] [--brand [NAME]] [--model AGENT=MODEL]
7
+ [--suffix TEXT]
8
+ patch-cc status
9
+ patch-cc doctor
10
+ patch-cc list
11
+ patch-cc restore
12
+ patch-cc extract PATH # dump the JS bundle (debugging)
13
+
14
+ Patch ids are positional; nothing selected means the default set. The two
15
+ configurable patches ride on their option: ``--brand`` selects branding,
16
+ ``--model`` selects subagent-models. Agents and models are validated against
17
+ what the installed binary itself offers.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import sys
24
+
25
+ from . import locate, patcher
26
+ from .bun import BunError
27
+ from .patches import Options, by_group, default_ids, derived_brand, ids
28
+ from .patches.agents import INHERIT, discover_agents, discover_models
29
+ from .ui import console, err, heading, ok, warn
30
+
31
+ #: ``--brand`` with no value: derive the name from the system username.
32
+ _DERIVE = ""
33
+
34
+
35
+ def _parse_models(specs: list[str], source: str) -> dict[str, str]:
36
+ """Validate ``AGENT=MODEL`` pairs against what this bundle offers."""
37
+ agents = {a.name: a for a in discover_agents(source)}
38
+ models = [INHERIT, *discover_models(source)]
39
+ overrides: dict[str, str] = {}
40
+ for spec in specs:
41
+ agent, sep, model = spec.partition("=")
42
+ if not sep or agent not in agents or model not in models:
43
+ err(f"--model expects AGENT=MODEL, got {spec!r}")
44
+ console.print(
45
+ f" [dim]agents in this binary: {', '.join(sorted(agents))}[/dim]"
46
+ )
47
+ console.print(f" [dim]models in this binary: {', '.join(models)}[/dim]")
48
+ raise SystemExit(2)
49
+ overrides[agent] = model
50
+ return overrides
51
+
52
+
53
+ def _requested(args, source: str) -> tuple[list[str], Options]:
54
+ """Build the patch set and options purely from CLI args -- no saved state.
55
+
56
+ Non-interactive patching is deliberately stateless: what you pass is
57
+ exactly what you get, the default set when you pass nothing.
58
+ """
59
+ selected = list(args.patches) if args.patches else default_ids()
60
+ unknown = [p for p in selected if p not in ids()]
61
+ if unknown:
62
+ err(f"unknown patch id(s): {', '.join(unknown)}")
63
+ console.print(f" [dim]valid ids: {', '.join(ids())}[/dim]")
64
+ raise SystemExit(2)
65
+
66
+ options = Options()
67
+ if args.brand is not None:
68
+ options.brand = args.brand.strip() or derived_brand()
69
+ if "branding" not in selected:
70
+ selected.append("branding")
71
+ elif "branding" in selected:
72
+ options.brand = derived_brand()
73
+
74
+ if args.suffix:
75
+ options.version_suffix = args.suffix
76
+ if "version-marker" not in selected:
77
+ selected.append("version-marker")
78
+
79
+ if args.model:
80
+ options.subagent_models = _parse_models(args.model, source)
81
+ if "subagent-models" not in selected:
82
+ selected.append("subagent-models")
83
+ elif "subagent-models" in selected:
84
+ err("subagent-models needs at least one --model AGENT=MODEL")
85
+ agents = discover_agents(source)
86
+ if agents:
87
+ console.print(
88
+ f" [dim]agents in this binary: "
89
+ f"{', '.join(sorted(a.name for a in agents))}[/dim]"
90
+ )
91
+ raise SystemExit(2)
92
+
93
+ return selected, options
94
+
95
+
96
+ def _print_report(report: patcher.PatchReport) -> None:
97
+ heading("Patch results")
98
+ for patch, outcome in report.results:
99
+ missed = outcome.missed_steps()
100
+ if outcome.landed and not missed:
101
+ mark = "[green]✓[/green]"
102
+ elif outcome.landed:
103
+ mark = "[yellow]~[/yellow]"
104
+ else:
105
+ mark = "[red]✗[/red]"
106
+ detail = f" applied {outcome.applied}" if outcome.applied else ""
107
+ console.print(f" {mark} {patch.title:28s}{detail}")
108
+ for name in missed:
109
+ console.print(
110
+ f" [yellow]· sub-step matched but not applied:[/yellow] {name}"
111
+ )
112
+ for note in outcome.notes:
113
+ console.print(f" [dim]· {note}[/dim]")
114
+
115
+ if report.output is None:
116
+ console.print()
117
+ err("No patch changed anything; the binary was left untouched.")
118
+ console.print(" [dim]Run `patch-cc doctor` for anchor details.[/dim]")
119
+ return
120
+
121
+ saved = report.original_size - report.patched_size
122
+ size_note = (
123
+ f"{abs(saved) / 1e6:.0f} MB smaller than original"
124
+ if saved > 0
125
+ else f"{abs(saved) / 1e6:.0f} MB larger than original"
126
+ if saved < 0
127
+ else "same size as original"
128
+ )
129
+ console.print()
130
+ ok(f"Wrote {report.output} ({report.patched_size / 1e6:.0f} MB, {size_note})")
131
+ if report.backup:
132
+ console.print(f" [dim]backup: {report.backup}[/dim]")
133
+ if report.regressions:
134
+ warn(
135
+ f"{len(report.regressions)} patch(es) matched nothing: "
136
+ + ", ".join(p.id for p in report.regressions)
137
+ )
138
+ console.print(" [dim]Run `patch-cc doctor` for anchor details.[/dim]")
139
+
140
+
141
+ def cmd_apply(args) -> int:
142
+ install = locate.find_or_raise()
143
+ bundle = patcher.read_pristine(install)
144
+ selected, options = _requested(args, bundle.source)
145
+
146
+ heading(f"Patching Claude {install.version or '?'} ({install.binary.name})")
147
+ try:
148
+ report = patcher.patch_installation(install, selected, options, bundle=bundle)
149
+ except patcher.AlreadyPatchedError as exc:
150
+ warn(str(exc))
151
+ return 1
152
+ except BunError as exc:
153
+ err(str(exc))
154
+ return 1
155
+
156
+ _print_report(report)
157
+ if report.output is not None:
158
+ console.print("\n[dim]Restart Claude Code for changes to take effect.[/dim]")
159
+ return 0 if report.output is not None else 1
160
+
161
+
162
+ def cmd_status(args) -> int:
163
+ from . import doctor
164
+ from .bun import container
165
+
166
+ install = locate.find_or_raise()
167
+ bundle = container.read(str(install.binary))
168
+ st = doctor.status(bundle)
169
+
170
+ heading(f"Claude {install.version or '?'} ({install.binary})")
171
+ state = "[green]patched[/green]" if st.patched else "[yellow]not patched[/yellow]"
172
+ console.print(f" state: {state}")
173
+ if st.manifest:
174
+ tool = st.manifest.get("tool", "?")
175
+ console.print(f" by: patch-cc {tool}")
176
+ console.print(f" patches: {', '.join(st.patch_ids) or '-'}")
177
+ if "brand" in st.manifest:
178
+ console.print(f" brand: {st.manifest['brand']}")
179
+ if "suffix" in st.manifest:
180
+ console.print(f" suffix: {st.manifest['suffix']}")
181
+ for agent, model in (st.manifest.get("models") or {}).items():
182
+ console.print(f" model: {agent} = {model}")
183
+ elif st.patched:
184
+ console.print(
185
+ " [dim]patched by an older patch-cc (no manifest); "
186
+ "re-apply to record one[/dim]"
187
+ )
188
+ console.print(
189
+ f" bytecode: "
190
+ f"{'stripped' if st.bytecode_stripped else f'{st.bytecode_size / 1e6:.0f} MB present'}"
191
+ )
192
+ backup = patcher.backup_path_for(install)
193
+ if backup.exists():
194
+ console.print(f" backup: {backup}")
195
+ if install.is_symlinked:
196
+ console.print(f" [dim]launcher {install.launcher} -> {install.binary}[/dim]")
197
+ return 0
198
+
199
+
200
+ def cmd_doctor(args) -> int:
201
+ from . import doctor
202
+ from .bun import container
203
+
204
+ install = locate.find_or_raise()
205
+ installed = container.read(str(install.binary))
206
+
207
+ # Matcher health must be tested on a clean bundle. If the installed binary
208
+ # is already patched, our edits have removed the anchors, so fall back to
209
+ # the pristine backup.
210
+ test_bundle = installed
211
+ source_note = ""
212
+ if patcher.is_patched(installed.source):
213
+ clean = patcher.clean_source_path(install)
214
+ if clean is None:
215
+ warn("Installed binary is already patched and no clean backup exists.")
216
+ console.print(
217
+ " [dim]Matcher health can't be checked against a patched binary. "
218
+ "Run `patch-cc restore`, or test a freshly downloaded binary.[/dim]"
219
+ )
220
+ return 1
221
+ test_bundle = container.read(str(clean))
222
+ source_note = (
223
+ " [dim](installed binary is patched; testing against backup)[/dim]"
224
+ )
225
+
226
+ result = doctor.dryrun(test_bundle)
227
+
228
+ heading(f"Patch health against Claude {install.version or '?'}{source_note}")
229
+ for check in result.checks:
230
+ outcome = check.outcome
231
+ missed = outcome.missed_steps()
232
+ if outcome.landed and not missed:
233
+ mark, colour = "✓", "green"
234
+ elif outcome.landed:
235
+ mark, colour = "~", "yellow"
236
+ else:
237
+ mark, colour = "✗", "red"
238
+ console.print(
239
+ f" [{colour}]{mark}[/{colour}] {check.patch.id:20s} "
240
+ f"cand={outcome.candidates} applied={outcome.applied}"
241
+ )
242
+ for name in missed:
243
+ sub = outcome.steps[name]
244
+ console.print(
245
+ f" [yellow]sub-step {name} missed[/yellow] "
246
+ f"{'· ' + '; '.join(sub.notes) if sub.notes else ''}"
247
+ )
248
+
249
+ agents = (
250
+ ", ".join(f"{a.name}={a.effective_model}" for a in result.agents)
251
+ or "none found"
252
+ )
253
+ console.print(f"\n [dim]agents: {agents}[/dim]")
254
+ console.print(f" [dim]models: {', '.join(result.models)}[/dim]")
255
+
256
+ if result.broken:
257
+ console.print()
258
+ warn(f"{len(result.broken)} patch(es) no longer match. Anchor counts:")
259
+ for check in result.broken:
260
+ anchors = result.anchors.get(check.patch.id, {})
261
+ for anchor, count in anchors.items():
262
+ colour = "red" if count == 0 else "dim"
263
+ console.print(f" [{colour}]{count:3d}[/{colour}] {anchor}")
264
+ console.print(
265
+ "\n [dim]A 0 next to an anchor is where upstream moved. "
266
+ "See docs/PLAYBOOK.md to repair.[/dim]"
267
+ )
268
+ return 1
269
+ ok("All patches still match this build.")
270
+ return 0
271
+
272
+
273
+ def cmd_list(args) -> int:
274
+ heading("Available patches")
275
+ for group, patches in by_group().items():
276
+ if not patches:
277
+ continue
278
+ console.print(f"\n[bold]{group}[/bold]")
279
+ for patch in patches:
280
+ tag = "[dim](via --model)[/dim]" if not patch.default else ""
281
+ console.print(f" [cyan]{patch.id:18s}[/cyan] {patch.summary} {tag}")
282
+
283
+ install = locate.find()
284
+ if install is None:
285
+ return 0
286
+ try:
287
+ source = patcher.read_pristine(install).source
288
+ except (BunError, OSError):
289
+ return 0
290
+ agents = discover_agents(source)
291
+ if agents:
292
+ console.print(f"\n[bold]Subagents in Claude {install.version or '?'}[/bold]")
293
+ for agent in agents:
294
+ console.print(
295
+ f" [cyan]{agent.name:18s}[/cyan] default model: {agent.effective_model}"
296
+ )
297
+ console.print(
298
+ f" [dim]models: {', '.join([INHERIT, *discover_models(source)])}[/dim]"
299
+ )
300
+ return 0
301
+
302
+
303
+ def cmd_restore(args) -> int:
304
+ install = locate.find_or_raise()
305
+ try:
306
+ restored = patcher.restore(install)
307
+ except FileNotFoundError as exc:
308
+ err(str(exc))
309
+ return 1
310
+ ok(f"Restored {restored} from backup.")
311
+ console.print("[dim]Restart Claude Code for changes to take effect.[/dim]")
312
+ return 0
313
+
314
+
315
+ def cmd_extract(args) -> int:
316
+ from .bun import container
317
+
318
+ bundle = container.read(args.path)
319
+ sys.stdout.buffer.write(bundle.source.encode("utf8"))
320
+ sys.stdout.buffer.flush()
321
+ return 0
322
+
323
+
324
+ def cmd_menu(args) -> int:
325
+ from .menu import run_menu
326
+
327
+ return run_menu()
328
+
329
+
330
+ def build_parser() -> argparse.ArgumentParser:
331
+ parser = argparse.ArgumentParser(
332
+ prog="patch-cc",
333
+ description="Interactive patcher for the Claude Code native binary.",
334
+ )
335
+ parser.set_defaults(func=cmd_menu)
336
+ sub = parser.add_subparsers(dest="command")
337
+
338
+ p_apply = sub.add_parser("apply", help="apply patches to the installed binary")
339
+ p_apply.add_argument(
340
+ "patches",
341
+ nargs="*",
342
+ metavar="PATCH",
343
+ help="patch ids to apply (default: the default set)",
344
+ )
345
+ p_apply.add_argument(
346
+ "--brand",
347
+ nargs="?",
348
+ const=_DERIVE,
349
+ metavar="NAME",
350
+ help="custom startup name (no value: <username>'s Code)",
351
+ )
352
+ p_apply.add_argument(
353
+ "--model",
354
+ action="append",
355
+ metavar="AGENT=MODEL",
356
+ help="override a subagent's default model (repeatable)",
357
+ )
358
+ p_apply.add_argument(
359
+ "--suffix", metavar="TEXT", help="--version marker text (default: (patched))"
360
+ )
361
+ p_apply.set_defaults(func=cmd_apply)
362
+
363
+ sub.add_parser(
364
+ "status", help="show what is applied to the installed binary"
365
+ ).set_defaults(func=cmd_status)
366
+ sub.add_parser(
367
+ "doctor", help="check every patch still matches this build"
368
+ ).set_defaults(func=cmd_doctor)
369
+ sub.add_parser(
370
+ "list", help="list patches, and the agents/models in your binary"
371
+ ).set_defaults(func=cmd_list)
372
+ sub.add_parser(
373
+ "restore", help="restore the original binary from backup"
374
+ ).set_defaults(func=cmd_restore)
375
+
376
+ p_extract = sub.add_parser(
377
+ "extract", help="dump the JS bundle from a binary (debug)"
378
+ )
379
+ p_extract.add_argument("path", help="path to a Claude native binary")
380
+ p_extract.set_defaults(func=cmd_extract)
381
+
382
+ return parser
383
+
384
+
385
+ def main(argv: list[str] | None = None) -> int:
386
+ parser = build_parser()
387
+ args = parser.parse_args(argv)
388
+ try:
389
+ return args.func(args)
390
+ except (FileNotFoundError, BunError) as exc:
391
+ err(str(exc))
392
+ return 1
393
+ except KeyboardInterrupt:
394
+ console.print("\n[dim]cancelled[/dim]")
395
+ return 130
396
+
397
+
398
+ if __name__ == "__main__":
399
+ raise SystemExit(main())