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/patcher.py ADDED
@@ -0,0 +1,244 @@
1
+ """Orchestration: read a binary, run selected patches, write it back safely."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from . import __version__, locate
13
+ from .bun import Bundle, container
14
+ from .patches import ALL_PATCHES, DEFAULT_SUFFIX, SENTINEL, Options, Outcome, Patch
15
+
16
+ #: Every patched bundle ends with one comment line recording exactly what was
17
+ #: applied. Comments cannot collide with code, survive re-extraction, and make
18
+ #: ``status`` a parse instead of a guess -- value-flip patches leave no other
19
+ #: fingerprint.
20
+ MANIFEST_PREFIX = "//patch-cc "
21
+
22
+ #: Fingerprints of binaries patched by versions before the manifest existed.
23
+ _LEGACY_MARKER = "(Claude Code)\\n" + DEFAULT_SUFFIX
24
+
25
+
26
+ class AlreadyPatchedError(RuntimeError):
27
+ """The only source available is already patched; patching it would stack.
28
+
29
+ Our edits change lengths, so a second pass over a patched bundle corrupts
30
+ rather than updates. There is deliberately no force-override: when no
31
+ pristine backup exists the only honest fixes are ``restore`` or a
32
+ reinstall.
33
+ """
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class PatchReport:
38
+ version: str | None
39
+ kind: str
40
+ original_size: int
41
+ patched_size: int = 0
42
+ results: list[tuple[Patch, Outcome]] = field(default_factory=list)
43
+ backup: Path | None = None
44
+ output: Path | None = None
45
+
46
+ @property
47
+ def landed_ids(self) -> list[str]:
48
+ return [p.id for p, o in self.results if o.landed]
49
+
50
+ @property
51
+ def regressions(self) -> list[Patch]:
52
+ """Selected patches that were expected to change something but did not."""
53
+ return [p for p, o in self.results if not o.landed]
54
+
55
+ @property
56
+ def partial(self) -> list[tuple[Patch, list[str]]]:
57
+ """Patches that landed but had some sub-step miss."""
58
+ out = []
59
+ for patch, outcome in self.results:
60
+ missed = outcome.missed_steps()
61
+ if outcome.landed and missed:
62
+ out.append((patch, missed))
63
+ return out
64
+
65
+
66
+ def build_manifest(landed: list[str], options: Options) -> str:
67
+ payload: dict = {"v": 1, "tool": __version__, "patches": landed}
68
+ if options.rebrands:
69
+ payload["brand"] = options.brand
70
+ if options.version_suffix != DEFAULT_SUFFIX:
71
+ payload["suffix"] = options.version_suffix
72
+ if options.subagent_models:
73
+ payload["models"] = options.subagent_models
74
+ return "\n" + MANIFEST_PREFIX + json.dumps(payload, separators=(",", ":")) + "\n"
75
+
76
+
77
+ def read_manifest(source: str) -> dict | None:
78
+ """The applied-patch record, or ``None`` for pristine/legacy binaries."""
79
+ start = source.rfind("\n" + MANIFEST_PREFIX)
80
+ if start == -1:
81
+ return None
82
+ start += 1 + len(MANIFEST_PREFIX)
83
+ end = source.find("\n", start)
84
+ line = source[start:] if end == -1 else source[start:end]
85
+ try:
86
+ data = json.loads(line)
87
+ except json.JSONDecodeError:
88
+ return None
89
+ return data if isinstance(data, dict) else None
90
+
91
+
92
+ def is_patched(source: str) -> bool:
93
+ return (
94
+ ("\n" + MANIFEST_PREFIX) in source
95
+ or SENTINEL in source
96
+ or _LEGACY_MARKER in source
97
+ )
98
+
99
+
100
+ def selected_patches(ids: list[str]) -> list[Patch]:
101
+ """Resolve ids to patches, preserving registry (run) order."""
102
+ wanted = set(ids)
103
+ return [patch for patch in ALL_PATCHES if patch.id in wanted]
104
+
105
+
106
+ def run_patches(
107
+ source: str, patches: list[Patch], options: Options
108
+ ) -> tuple[str, list[tuple[Patch, Outcome]]]:
109
+ results: list[tuple[Patch, Outcome]] = []
110
+ current = source
111
+ for patch in patches:
112
+ current, outcome = patch.run(current, options)
113
+ results.append((patch, outcome))
114
+ return current, results
115
+
116
+
117
+ def _backup_dir() -> Path:
118
+ base = os.environ.get("XDG_DATA_HOME")
119
+ root = Path(base) if base else Path.home() / ".local" / "share"
120
+ return root / "patch-cc" / "backups"
121
+
122
+
123
+ def backup_path_for(install: locate.Installation) -> Path:
124
+ """The single source of truth for where a binary's backup lives.
125
+
126
+ Canonical native installs are version-named, giving a clean
127
+ ``<name>.<version>.orig``. When the name is not a version we cannot tell two
128
+ unrelated ``claude`` binaries apart by name alone, so a short hash of the
129
+ absolute path is mixed in to keep their backups distinct.
130
+ """
131
+ root = _backup_dir()
132
+ if install.version:
133
+ stem = f"{install.binary.name}.{install.version}"
134
+ else:
135
+ digest = hashlib.sha256(str(install.binary.resolve()).encode()).hexdigest()[:8]
136
+ stem = f"{install.binary.name}.unknown-{digest}"
137
+ return root / f"{stem}.orig"
138
+
139
+
140
+ def read_pristine(install: locate.Installation) -> Bundle:
141
+ """The bundle patching starts from: the backup when one exists.
142
+
143
+ Patching never stacks edits on edits -- each apply begins at this pristine
144
+ source, so the selected set is always exactly what ends up in the binary.
145
+ """
146
+ backup = backup_path_for(install)
147
+ return container.read(str(backup if backup.exists() else install.binary))
148
+
149
+
150
+ def _backup(install: locate.Installation, *, pristine: bool) -> Path | None:
151
+ """Record the pristine original once, so ``restore`` is a plain copy back.
152
+
153
+ Only ever captures a binary that is actually unpatched: backing up an
154
+ already-marked binary would enshrine a poisoned "original" that ``restore``
155
+ would later hand back as clean.
156
+ """
157
+ dest = backup_path_for(install)
158
+ if dest.exists():
159
+ return dest
160
+ if not pristine:
161
+ return None
162
+ dest.parent.mkdir(parents=True, exist_ok=True)
163
+ shutil.copy2(install.binary, dest)
164
+ return dest
165
+
166
+
167
+ def patch_installation(
168
+ install: locate.Installation,
169
+ selected: list[str],
170
+ options: Options,
171
+ *,
172
+ bundle: Bundle | None = None,
173
+ out_path: Path | None = None,
174
+ make_backup: bool = True,
175
+ ) -> PatchReport:
176
+ """Patch ``install`` (or write to ``out_path``) with the ``selected`` patches.
177
+
178
+ Patching always starts from a pristine source (:func:`read_pristine`), so
179
+ re-applying replaces the previous patch set instead of stacking on it.
180
+ ``bundle`` may carry that already-read source (the CLI reads it for
181
+ validation first).
182
+ """
183
+ source = bundle if bundle is not None else read_pristine(install)
184
+ if is_patched(source.source):
185
+ raise AlreadyPatchedError(
186
+ f"{install.binary} is already patched and no pristine backup exists, "
187
+ "so there is nothing clean to patch from. Run `patch-cc restore`, "
188
+ "or reinstall Claude to get a clean binary."
189
+ )
190
+
191
+ patches = selected_patches(selected)
192
+ patched_source, results = run_patches(source.source, patches, options)
193
+
194
+ report = PatchReport(
195
+ version=install.version,
196
+ kind=source.kind,
197
+ original_size=source.binary_size,
198
+ results=results,
199
+ )
200
+
201
+ landed = report.landed_ids
202
+ if not landed:
203
+ # Nothing changed; writing would only strip bytecode for no benefit.
204
+ return report
205
+
206
+ patched_source += build_manifest(landed, options)
207
+
208
+ target = out_path or install.binary
209
+ if make_backup and out_path is None:
210
+ report.backup = _backup(install, pristine=not is_patched(source.source))
211
+
212
+ container.write(source, patched_source, str(target))
213
+ report.output = Path(target)
214
+ report.patched_size = Path(target).stat().st_size
215
+ return report
216
+
217
+
218
+ def clean_source_path(install: locate.Installation) -> Path | None:
219
+ """A binary whose bundle is guaranteed unpatched, for matcher-health tests.
220
+
221
+ If the installed binary is already patched, our own edits have removed the
222
+ anchors the matchers look for, so a dry-run against it conflates
223
+ "already applied" with "anchor gone". The pristine backup is the honest
224
+ thing to test against.
225
+ """
226
+ backup = backup_path_for(install)
227
+ return backup if backup.exists() else None
228
+
229
+
230
+ def restore(install: locate.Installation) -> Path:
231
+ """Copy the pristine backup back over the installed binary."""
232
+ backup = backup_path_for(install)
233
+ if not backup.exists():
234
+ raise FileNotFoundError(
235
+ f"No backup found for {install.binary.name} "
236
+ f"{install.version or '(unknown version)'} at {backup}. "
237
+ "If Claude auto-updated, the original for this version was never saved -- "
238
+ "reinstall to get a clean binary."
239
+ )
240
+ # A full-file copy-back, so it works for both ELF and Mach-O.
241
+ from .bun.elf import atomic_write # noqa: PLC0415
242
+
243
+ atomic_write(str(install.binary), backup.read_bytes(), mode_from=str(backup))
244
+ return install.binary
@@ -0,0 +1,75 @@
1
+ """The patch registry.
2
+
3
+ Patches run in registration order and each sees the previous one's output.
4
+ The order below is the upstream order; do not reorder casually -- some patches
5
+ depend on regions an earlier one leaves untouched.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from . import agents, chrome, output, streaming, thinking
11
+ from .base import (
12
+ DEFAULT_BRAND,
13
+ DEFAULT_SUFFIX,
14
+ GROUP_AGENTS,
15
+ GROUP_CHROME,
16
+ GROUP_LIVE,
17
+ GROUP_OUTPUT,
18
+ GROUP_THINKING,
19
+ SENTINEL,
20
+ Options,
21
+ Outcome,
22
+ Patch,
23
+ derived_brand,
24
+ )
25
+
26
+ ALL_PATCHES: list[Patch] = [
27
+ *output.PATCHES,
28
+ *thinking.PATCHES,
29
+ *streaming.PATCHES,
30
+ *agents.PATCHES,
31
+ *chrome.PATCHES,
32
+ ]
33
+
34
+ GROUP_ORDER = [GROUP_OUTPUT, GROUP_THINKING, GROUP_LIVE, GROUP_AGENTS, GROUP_CHROME]
35
+
36
+ _BY_ID = {patch.id: patch for patch in ALL_PATCHES}
37
+
38
+
39
+ def get(patch_id: str) -> Patch:
40
+ try:
41
+ return _BY_ID[patch_id]
42
+ except KeyError:
43
+ raise KeyError(f"unknown patch id: {patch_id}") from None
44
+
45
+
46
+ def ids() -> list[str]:
47
+ return [patch.id for patch in ALL_PATCHES]
48
+
49
+
50
+ def default_ids() -> list[str]:
51
+ return [patch.id for patch in ALL_PATCHES if patch.default]
52
+
53
+
54
+ def by_group() -> dict[str, list[Patch]]:
55
+ grouped: dict[str, list[Patch]] = {group: [] for group in GROUP_ORDER}
56
+ for patch in ALL_PATCHES:
57
+ grouped.setdefault(patch.group, []).append(patch)
58
+ return grouped
59
+
60
+
61
+ __all__ = [
62
+ "ALL_PATCHES",
63
+ "DEFAULT_BRAND",
64
+ "DEFAULT_SUFFIX",
65
+ "GROUP_ORDER",
66
+ "Options",
67
+ "Outcome",
68
+ "Patch",
69
+ "SENTINEL",
70
+ "derived_brand",
71
+ "get",
72
+ "ids",
73
+ "default_ids",
74
+ "by_group",
75
+ ]
@@ -0,0 +1,289 @@
1
+ """Subagent patches: prompt visibility, and overriding built-in models.
2
+
3
+ Everything the model override offers is discovered from the bundle itself:
4
+
5
+ * **Agents** come from the built-in definition shape
6
+ ``agentType:"<name>",whenToUse:...`` carrying ``source:"built-in"``.
7
+ * **Models** come from the Task tool's own input schema -- the
8
+ ``model:enum([...])`` whose describe-string starts "Optional model override".
9
+
10
+ So a new upstream agent or model shows up here without a code change, and we
11
+ can never offer a name the binary would reject.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+ from .base import GROUP_AGENTS, IDENT, Options, Outcome, Patch, compile_js, splice
20
+
21
+ # ------------------------------------------------------- prompt visibility
22
+
23
+ _BACKGROUNDED = '"Backgrounded agent"'
24
+ _LIVE_PROMPT_MOUNT = compile_js(
25
+ rf"({IDENT})&&({IDENT})&&({IDENT})\.createElement\(m,\{{marginBottom:1\}},"
26
+ rf"\3\.createElement\(({IDENT}),\{{prompt:\2\}}\)\)"
27
+ )
28
+ _EMPTY_STATE = compile_js(
29
+ rf"if\(({IDENT})\.length===0&&!?\(({IDENT})&&({IDENT})\)\)return"
30
+ )
31
+ _TRANSCRIPT_MODE = compile_js(rf"isTranscriptMode:({IDENT})=!1")
32
+
33
+
34
+ def _subagent_prompt(content: str, _options: Options, outcome: Outcome) -> str:
35
+ """Show the subagent ``Prompt`` block outside transcript mode."""
36
+ gate = outcome.step("gate")
37
+ output = content
38
+ index = 0
39
+
40
+ while True:
41
+ anchor = output.find(_BACKGROUNDED, index)
42
+ if anchor == -1:
43
+ break
44
+ fn_start = output.rfind("function ", 0, anchor)
45
+ fn_end_candidate = output.find("function ", anchor + len(_BACKGROUNDED))
46
+ fn_end = len(output) if fn_end_candidate == -1 else fn_end_candidate
47
+ index = anchor + len(_BACKGROUNDED)
48
+ if fn_start == -1 or fn_end <= fn_start:
49
+ continue
50
+
51
+ segment = output[fn_start:fn_end]
52
+ relevant = (
53
+ 'action:"app:toggleTranscript"' in segment
54
+ and 'fallback:"ctrl+o"' in segment
55
+ and "isTranscriptMode:" in segment
56
+ and "{prompt:" in segment
57
+ and ",theme:" in segment
58
+ )
59
+ if not relevant:
60
+ continue
61
+
62
+ transcript = _TRANSCRIPT_MODE.search(segment)
63
+ if not transcript:
64
+ continue
65
+ transcript_var = transcript.group(1)
66
+ gate_pattern = compile_js(rf"{re.escape(transcript_var)}&&({IDENT})&&")
67
+
68
+ def drop_gate(match: re.Match[str]) -> str:
69
+ prompt_var = match.group(1)
70
+ nearby = segment[match.end() : match.end() + 260]
71
+ if f"{{prompt:{prompt_var},theme:" not in nearby:
72
+ return match.group(0)
73
+ gate.candidates += 1
74
+ gate.applied += 1
75
+ return f"{prompt_var}&&"
76
+
77
+ next_segment = gate_pattern.sub(drop_gate, segment)
78
+ if next_segment != segment:
79
+ output = splice(output, fn_start, fn_end, next_segment)
80
+ index = fn_start + len(next_segment)
81
+
82
+ mount = outcome.step("mount")
83
+
84
+ def rewrite_mount(match: re.Match[str]) -> str:
85
+ _transcript, prompt_var, ns, component = match.groups()
86
+ mount.candidates += 1
87
+ replacement = (
88
+ f"{prompt_var}&&{ns}.createElement(m,{{marginBottom:1}},"
89
+ f"{ns}.createElement({component},{{prompt:{prompt_var}}}))"
90
+ )
91
+ if replacement != match.group(0):
92
+ mount.applied += 1
93
+ return replacement
94
+
95
+ output = _LIVE_PROMPT_MOUNT.sub(rewrite_mount, output)
96
+
97
+ empty = outcome.step("empty-state")
98
+
99
+ def rewrite_empty(match: re.Match[str]) -> str:
100
+ rows, _transcript, prompt_var = match.groups()
101
+ empty.candidates += 1
102
+ replacement = f"if({rows}.length===0&&!{prompt_var})return"
103
+ if replacement != match.group(0):
104
+ empty.applied += 1
105
+ return replacement
106
+
107
+ return _EMPTY_STATE.sub(rewrite_empty, output)
108
+
109
+
110
+ # ----------------------------------------------------------- discovery
111
+
112
+ #: Always offered besides the discovered aliases: keep the agent on whatever
113
+ #: the main loop runs.
114
+ INHERIT = "inherit"
115
+
116
+ _AGENT_DEF = compile_js(r'agentType:"([\w-]+)",whenToUse:')
117
+ _MODEL_FIELD = compile_js(r'model:"([\w\[\].-]+)"')
118
+ #: A definition object is scanned at most this far; every known definition fits
119
+ #: well within it, and the cap keeps a moved anchor from swallowing a neighbour.
120
+ _DEF_WINDOW = 3000
121
+
122
+ _MODEL_ENUM = compile_js(
123
+ rf'model:{IDENT}\.enum\(\[((?:"[\w\[\]]+",?)+)\]\)\.optional\(\)'
124
+ rf'\.describe\([`"]Optional model override'
125
+ )
126
+ #: Used only if the Task-tool schema anchor ever disappears.
127
+ _FALLBACK_MODELS = ("haiku", "sonnet", "opus")
128
+
129
+
130
+ @dataclass(slots=True, frozen=True)
131
+ class BuiltinAgent:
132
+ """One built-in agent definition as found in a bundle."""
133
+
134
+ name: str
135
+ #: Current ``model:"..."`` literal, or ``None`` when the definition has no
136
+ #: model field (which the runtime treats as inherit).
137
+ model: str | None
138
+ #: Offset of the definition anchor in the scanned source.
139
+ start: int
140
+ #: Offset of the model *value* inside the source, ``-1`` when absent.
141
+ model_start: int
142
+ #: Where a ``model:"...",`` property would be inserted.
143
+ insert_at: int
144
+
145
+ @property
146
+ def effective_model(self) -> str:
147
+ return self.model or INHERIT
148
+
149
+
150
+ def discover_agents(source: str) -> list[BuiltinAgent]:
151
+ """Built-in agent definitions as they exist in *this* bundle.
152
+
153
+ Definitions marked internal (their ``whenToUse`` says so) are not offered:
154
+ they are orchestration plumbing, not agents a user chooses.
155
+ """
156
+ agents: list[BuiltinAgent] = []
157
+ seen: set[str] = set()
158
+ for match in _AGENT_DEF.finditer(source):
159
+ name = match.group(1)
160
+ window = source[match.start() : match.start() + _DEF_WINDOW]
161
+ stop = window.find("getSystemPrompt:")
162
+ span = window if stop == -1 else window[:stop]
163
+ if 'source:"built-in"' not in span or 'whenToUse:"Internal' in span:
164
+ continue
165
+ if name in seen:
166
+ continue
167
+ seen.add(name)
168
+ field = _MODEL_FIELD.search(span)
169
+ agents.append(
170
+ BuiltinAgent(
171
+ name=name,
172
+ model=field.group(1) if field else None,
173
+ start=match.start(),
174
+ model_start=match.start() + field.start(1) if field else -1,
175
+ insert_at=match.end() - len("whenToUse:"),
176
+ )
177
+ )
178
+ return agents
179
+
180
+
181
+ def discover_models(source: str) -> list[str]:
182
+ """Model aliases the binary's own Task tool accepts for subagents."""
183
+ match = _MODEL_ENUM.search(source)
184
+ if not match:
185
+ return list(_FALLBACK_MODELS)
186
+ return re.findall(r'"([\w\[\]]+)"', match.group(1))
187
+
188
+
189
+ # --------------------------------------------------------- model overrides
190
+
191
+ # One helper resolves a built-in agent's default model and, for exactly one
192
+ # agent (Explore today), ignores the definition's model field in favour of its
193
+ # own pin. Overriding that agent means neutralising this bypass so the
194
+ # definition -- which we just rewrote -- is authoritative again.
195
+ _MODEL_BYPASS = compile_js(
196
+ rf"function ({IDENT})\(({IDENT}),({IDENT})\)\{{"
197
+ rf'if\(\2\.agentType!==({IDENT})\.agentType\|\|\2\.source!=="built-in"\)return \2\.model;'
198
+ rf'return {IDENT}\(\3\)\?{IDENT}:"inherit"\}}'
199
+ )
200
+
201
+
202
+ def bypassed_agent(source: str) -> str | None:
203
+ """Name of the agent whose definition the bypass helper overrides, if any."""
204
+ match = _MODEL_BYPASS.search(source)
205
+ if not match:
206
+ return None
207
+ def_var = re.escape(match.group(4))
208
+ assign = compile_js(rf'(?<![\w$]){def_var}=\{{agentType:"([\w-]+)"').search(source)
209
+ return assign.group(1) if assign else None
210
+
211
+
212
+ def _neutralize_bypass(content: str, step: Outcome) -> str:
213
+ def rewrite(match: re.Match[str]) -> str:
214
+ step.candidates += 1
215
+ step.applied += 1
216
+ name, obj, model = match.group(1, 2, 3)
217
+ return f"function {name}({obj},{model}){{return {obj}.model}}"
218
+
219
+ output = _MODEL_BYPASS.sub(rewrite, content, count=1)
220
+ if step.candidates == 0:
221
+ step.note(
222
+ "model-bypass helper not found; the pinned agent may keep its own default"
223
+ )
224
+ return output
225
+
226
+
227
+ def _subagent_models(content: str, options: Options, outcome: Outcome) -> str:
228
+ """Write the chosen model into each overridden built-in definition.
229
+
230
+ Definitions with a ``model:"..."`` literal get it rewritten; definitions
231
+ without one get it inserted. Both target offsets from a fresh discovery
232
+ pass, so this never guesses about the bytes between anchor and value.
233
+ """
234
+ if not options.subagent_models:
235
+ outcome.note("no subagent model overrides configured")
236
+ return content
237
+
238
+ output = content
239
+ offered = {INHERIT, *discover_models(output)}
240
+
241
+ for agent, target in sorted(options.subagent_models.items()):
242
+ step = outcome.step(agent)
243
+ if target not in offered:
244
+ step.note(f"model {target!r} not offered by this bundle; skipped")
245
+ continue
246
+ info = next((a for a in discover_agents(output) if a.name == agent), None)
247
+ if info is None:
248
+ step.note(f"no built-in agent {agent!r} in this bundle; skipped")
249
+ continue
250
+
251
+ step.candidates += 1
252
+ if info.effective_model == target:
253
+ continue # already the desired model
254
+ if info.model is None:
255
+ output = splice(
256
+ output, info.insert_at, info.insert_at, f'model:"{target}",'
257
+ )
258
+ else:
259
+ output = splice(
260
+ output, info.model_start, info.model_start + len(info.model), target
261
+ )
262
+ step.applied += 1
263
+
264
+ pinned = bypassed_agent(output)
265
+ if pinned is not None and pinned in options.subagent_models:
266
+ output = _neutralize_bypass(output, outcome.step("model-bypass"))
267
+
268
+ return output
269
+
270
+
271
+ PATCHES = [
272
+ Patch(
273
+ id="subagent-prompt",
274
+ title="Show subagent prompts",
275
+ summary="Show a subagent's Prompt block during normal use, not only in transcript mode.",
276
+ group=GROUP_AGENTS,
277
+ fn=_subagent_prompt,
278
+ anchors=('"Backgrounded agent"', 'action:"app:toggleTranscript"'),
279
+ ),
280
+ Patch(
281
+ id="subagent-models",
282
+ title="Override subagent models",
283
+ summary="Choose the default model for the built-in agents found in your binary.",
284
+ group=GROUP_AGENTS,
285
+ fn=_subagent_models,
286
+ default=False,
287
+ anchors=('agentType:"', "Optional model override"),
288
+ ),
289
+ ]