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/__init__.py +3 -0
- patch_cc/bun/__init__.py +6 -0
- patch_cc/bun/blob.py +256 -0
- patch_cc/bun/container.py +129 -0
- patch_cc/bun/elf.py +281 -0
- patch_cc/bun/errors.py +12 -0
- patch_cc/bun/macho.py +124 -0
- patch_cc/cache.py +86 -0
- patch_cc/cli.py +399 -0
- patch_cc/doctor.py +116 -0
- patch_cc/locate.py +117 -0
- patch_cc/menu.py +1135 -0
- patch_cc/patcher.py +244 -0
- patch_cc/patches/__init__.py +75 -0
- patch_cc/patches/agents.py +289 -0
- patch_cc/patches/base.py +200 -0
- patch_cc/patches/chrome.py +187 -0
- patch_cc/patches/output.py +173 -0
- patch_cc/patches/streaming.py +922 -0
- patch_cc/patches/thinking.py +88 -0
- patch_cc/ui.py +23 -0
- patch_cc-0.1.0.dist-info/METADATA +141 -0
- patch_cc-0.1.0.dist-info/RECORD +26 -0
- patch_cc-0.1.0.dist-info/WHEEL +4 -0
- patch_cc-0.1.0.dist-info/entry_points.txt +2 -0
- patch_cc-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|