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.
@@ -0,0 +1,200 @@
1
+ """Patch framework: how a single rewrite of the bundle is described and run.
2
+
3
+ Matcher rules, learned the hard way upstream (see docs/PLAYBOOK.md):
4
+
5
+ * Never anchor on minified locals (``A_``, ``mET``, ``wg6``); they are
6
+ regenerated on every upstream build.
7
+ * Anchor on string literals, ``case`` labels, prop names, or control-flow shape.
8
+ * When upstream ships several shapes, add a second narrow branch rather than
9
+ widening one regex until it over-matches.
10
+
11
+ Porting rules, for anyone translating more of upstream's JS:
12
+
13
+ * JS ``.replace(re, fn)`` without ``/g`` replaces **once** -- that is
14
+ ``re.sub(..., count=1)``. Python's default replaces every occurrence.
15
+ * JS ``.replace("a", "b")`` on plain strings also replaces once --
16
+ ``str.replace(a, b, 1)``.
17
+ * Compile with :data:`re.ASCII` so ``\\w`` stays ASCII as it is in JS.
18
+ * Always pass a *function* to :func:`re.sub`; a string template would treat
19
+ backslashes in the replacement as escapes.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass, field
27
+
28
+ #: Prefix for every identifier we inject. These are the only reliable
29
+ #: fingerprints of our work -- value flips like ``verbose:!0`` are
30
+ #: indistinguishable from the 11 that upstream already ships.
31
+ SENTINEL = "__cc_"
32
+
33
+ # Groups, in display order.
34
+ GROUP_OUTPUT = "Output & diffs"
35
+ GROUP_THINKING = "Thinking"
36
+ GROUP_LIVE = "Live thinking (streaming)"
37
+ GROUP_AGENTS = "Subagents"
38
+ GROUP_CHROME = "Chrome & branding"
39
+
40
+ #: Default brand: the name shown unless the user overrides it. One home so the
41
+ #: field default and the "is it rebranded?" test can never disagree.
42
+ DEFAULT_BRAND = "Claude Code"
43
+
44
+ #: Default --version marker text.
45
+ DEFAULT_SUFFIX = "(patched)"
46
+
47
+
48
+ def derived_brand() -> str:
49
+ """The branding default: the system username, possessive.
50
+
51
+ ``anfreire`` becomes ``anfreire's Code``. Falls back to the unbranded
52
+ default when no username can be determined.
53
+ """
54
+ import getpass
55
+
56
+ try:
57
+ user = getpass.getuser().strip()
58
+ except OSError:
59
+ user = ""
60
+ return f"{user}'s Code" if user else DEFAULT_BRAND
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class Options:
65
+ """User customisation handed to every patch."""
66
+
67
+ brand: str = DEFAULT_BRAND
68
+ version_suffix: str = DEFAULT_SUFFIX
69
+ subagent_models: dict[str, str] = field(default_factory=dict)
70
+
71
+ @property
72
+ def rebrands(self) -> bool:
73
+ return self.brand != DEFAULT_BRAND
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class Outcome:
78
+ """What a patch found and what it changed.
79
+
80
+ ``candidates`` and ``applied`` describe different failures and must not be
81
+ collapsed into one number:
82
+
83
+ * ``candidates == 0`` -- the anchor is gone. A real regression.
84
+ * ``candidates > 0, applied == 0`` -- shape found, rewrite was a no-op.
85
+ Usually means already patched, not broken.
86
+ """
87
+
88
+ candidates: int = 0
89
+ applied: int = 0
90
+ notes: list[str] = field(default_factory=list)
91
+ #: Named sub-steps, for patches built from several independent rewrites.
92
+ steps: dict[str, "Outcome"] = field(default_factory=dict)
93
+
94
+ @property
95
+ def landed(self) -> bool:
96
+ return self.applied > 0
97
+
98
+ def note(self, message: str) -> None:
99
+ self.notes.append(message)
100
+
101
+ def step(self, name: str) -> "Outcome":
102
+ """Get (or create) a named sub-outcome.
103
+
104
+ A single scalar count cannot distinguish "all twelve rewrites landed"
105
+ from "six landed and six silently drifted" -- which is exactly how
106
+ upstream's live-thinking patch hides its own regressions. Recording each
107
+ rewrite separately turns that into an actionable "reducer.message_stop
108
+ missed".
109
+ """
110
+ return self.steps.setdefault(name, Outcome())
111
+
112
+ def finalize(self) -> "Outcome":
113
+ """Roll sub-step totals up into this outcome."""
114
+ if self.steps:
115
+ self.candidates += sum(s.candidates for s in self.steps.values())
116
+ self.applied += sum(s.applied for s in self.steps.values())
117
+ return self
118
+
119
+ def missed_steps(self) -> list[str]:
120
+ """Sub-steps whose shape was *found* but which failed to rewrite.
121
+
122
+ A step that matched nothing (``candidates == 0``) is usually a shape
123
+ that simply is not on this build -- most patches carry several
124
+ mutually-exclusive version variants -- so it is reported separately by
125
+ :meth:`absent_steps`, not here. A step that found candidates yet applied
126
+ none is the genuine concern.
127
+ """
128
+ return [
129
+ name
130
+ for name, sub in self.steps.items()
131
+ if sub.candidates > 0 and not sub.landed
132
+ ]
133
+
134
+ def absent_steps(self) -> list[str]:
135
+ """Sub-steps that matched nothing on this build (informational)."""
136
+ return [name for name, sub in self.steps.items() if sub.candidates == 0]
137
+
138
+
139
+ PatchFn = Callable[[str, Options, Outcome], str]
140
+
141
+
142
+ @dataclass(slots=True)
143
+ class Patch:
144
+ id: str
145
+ title: str
146
+ summary: str
147
+ group: str
148
+ fn: PatchFn
149
+ default: bool = True
150
+ #: Anchors to report on when this patch stops matching.
151
+ anchors: tuple[str, ...] = ()
152
+
153
+ def run(self, content: str, options: Options) -> tuple[str, Outcome]:
154
+ outcome = Outcome()
155
+ try:
156
+ content = self.fn(content, options, outcome)
157
+ except Exception as exc: # noqa: BLE001 - one bad patch must not abort the run
158
+ outcome.note(f"raised {type(exc).__name__}: {exc}")
159
+ return content, outcome.finalize()
160
+
161
+
162
+ def compile_js(pattern: str, flags: int = 0) -> re.Pattern[str]:
163
+ """Compile a matcher with JS-compatible ``\\w`` semantics."""
164
+ return re.compile(pattern, flags | re.ASCII)
165
+
166
+
167
+ # Matches a minified identifier, the JS `[A-Za-z_$][\w$]*` idiom.
168
+ IDENT = r"[A-Za-z_$][\w$]*"
169
+
170
+
171
+ def switch_case_end(content: str, start: int) -> int:
172
+ """End offset of a ``switch`` arm beginning at ``start``.
173
+
174
+ Upstream's arms end at the next ``case"`` or ``default:``, whichever comes
175
+ first. Mirrors the scan every case-based patch does.
176
+ """
177
+ nxt_case = content.find('case"', start)
178
+ nxt_default = content.find("default:", start)
179
+ ends = [i for i in (nxt_case, nxt_default) if i != -1]
180
+ return min(ends) if ends else len(content)
181
+
182
+
183
+ def iter_segments(content: str, needle: str):
184
+ """Yield ``(start, end)`` for each switch arm introduced by ``needle``.
185
+
186
+ The caller rewrites and the generator is restarted, so this is deliberately
187
+ a simple finder rather than a stateful cursor.
188
+ """
189
+ index = 0
190
+ while True:
191
+ start = content.find(needle, index)
192
+ if start == -1:
193
+ return
194
+ end = switch_case_end(content, start + len(needle))
195
+ yield start, end
196
+ index = start + len(needle)
197
+
198
+
199
+ def splice(content: str, start: int, end: int, replacement: str) -> str:
200
+ return content[:start] + replacement + content[end:]
@@ -0,0 +1,187 @@
1
+ """UI chrome: spinner tips, the --version marker, and branding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .base import (
8
+ DEFAULT_SUFFIX,
9
+ GROUP_CHROME,
10
+ IDENT,
11
+ Options,
12
+ Outcome,
13
+ Patch,
14
+ compile_js,
15
+ )
16
+
17
+ # ---------------------------------------------------------- spinner tips
18
+
19
+ _SPINNER_GUARD = compile_js(rf"if\({IDENT}\(\)\.spinnerTipsEnabled===!1\)return;")
20
+ _SPINNER_EXPR = compile_js(rf"{IDENT}\.spinnerTipsEnabled!==!1")
21
+
22
+
23
+ def _disable_spinner_tips(content: str, _options: Options, outcome: Outcome) -> str:
24
+ """Force spinner tips off through both code paths that can enable them."""
25
+ guard = outcome.step("guard")
26
+
27
+ def kill_guard(_match: re.Match[str]) -> str:
28
+ guard.candidates += 1
29
+ guard.applied += 1
30
+ return "if(!0)return;"
31
+
32
+ output = _SPINNER_GUARD.sub(kill_guard, content)
33
+
34
+ expr = outcome.step("expr")
35
+
36
+ def kill_expr(_match: re.Match[str]) -> str:
37
+ expr.candidates += 1
38
+ expr.applied += 1
39
+ return "!1"
40
+
41
+ return _SPINNER_EXPR.sub(kill_expr, output)
42
+
43
+
44
+ # ---------------------------------------------------------- version marker
45
+
46
+ _VERSION_NEEDLE = "}.VERSION} (Claude Code)"
47
+
48
+
49
+ def _js_template_escape(text: str) -> str:
50
+ """Make arbitrary text safe inside a JS template literal."""
51
+ return text.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${")
52
+
53
+
54
+ def _version_output(content: str, options: Options, outcome: Outcome) -> str:
55
+ """Append a marker line to plain ``--version`` output.
56
+
57
+ The marker text is the user's ``--suffix`` (default ``(patched)``). The
58
+ needle sits inside a template literal, so the suffix is escaped for that
59
+ context.
60
+ """
61
+ marker = "\\n" + _js_template_escape(options.version_suffix or DEFAULT_SUFFIX)
62
+ output = content
63
+ index = output.find(_VERSION_NEEDLE)
64
+ while index != -1:
65
+ outcome.candidates += 1
66
+ marker_at = index + len(_VERSION_NEEDLE)
67
+ if output[marker_at : marker_at + len(marker)] == marker:
68
+ index = output.find(_VERSION_NEEDLE, marker_at + len(marker))
69
+ continue
70
+ output = output[:marker_at] + marker + output[marker_at:]
71
+ outcome.applied += 1
72
+ index = output.find(_VERSION_NEEDLE, marker_at + len(marker))
73
+ return output
74
+
75
+
76
+ # ---------------------------------------------------------- branding
77
+
78
+
79
+ def _branding(content: str, options: Options, outcome: Outcome) -> str:
80
+ """Rename visible ``Claude Code`` branding to the user's chosen name.
81
+
82
+ Only the small set of visible startup/help strings are touched, each as its
83
+ own step so partial upstream drift is visible.
84
+ """
85
+ if not options.rebrands:
86
+ outcome.note("brand unchanged; nothing to do")
87
+ return content
88
+
89
+ brand = options.brand
90
+ esc = brand.replace("\\", "\\\\").replace('"', '\\"')
91
+ output = content
92
+
93
+ def swap(step_name: str, pattern: re.Pattern[str], replace) -> None:
94
+ nonlocal output
95
+ step = outcome.step(step_name)
96
+
97
+ def rewrite(match: re.Match[str]) -> str:
98
+ step.candidates += 1
99
+ result = replace(match)
100
+ if result != match.group(0):
101
+ step.applied += 1
102
+ return result
103
+
104
+ output = pattern.sub(rewrite, output)
105
+
106
+ swap(
107
+ "bold-text",
108
+ compile_js(
109
+ rf'({IDENT})\.createElement\(({IDENT}),\{{bold:!0\}},"Claude Code"\)'
110
+ ),
111
+ lambda m: f'{m.group(1)}.createElement({m.group(2)},{{bold:!0}},"{esc}")',
112
+ )
113
+ swap(
114
+ "bold-jsx",
115
+ compile_js(
116
+ rf'({IDENT})\.(jsx|jsxs)\(({IDENT}),\{{bold:!0,children:"Claude Code"\}}\)'
117
+ ),
118
+ lambda m: (
119
+ f'{m.group(1)}.{m.group(2)}({m.group(3)},{{bold:!0,children:"{esc}"}})'
120
+ ),
121
+ )
122
+ swap(
123
+ "help-title",
124
+ compile_js(
125
+ r"title:(`Claude Code v\$\{[\s\S]*?\.VERSION\}`),"
126
+ r'color:"professionalBlue",defaultTab:"general"'
127
+ ),
128
+ lambda m: (
129
+ f'title:{m.group(1)}.replace("Claude Code","{esc}"),'
130
+ f'color:"professionalBlue",defaultTab:"general"'
131
+ ),
132
+ )
133
+ swap(
134
+ "welcome-for",
135
+ compile_js(r'"Welcome to Claude Code for "'),
136
+ lambda _m: f'"Welcome to {esc} for "',
137
+ )
138
+ swap(
139
+ "welcome",
140
+ compile_js(r'"Welcome to Claude Code"'),
141
+ lambda _m: f'"Welcome to {esc}"',
142
+ )
143
+ swap(
144
+ "children-array",
145
+ compile_js(r'(color:"claude",bold:!0,children:\[)"Claude Code"(," "\])'),
146
+ lambda m: f'{m.group(1)}"{esc}"{m.group(2)}',
147
+ )
148
+ swap(
149
+ "styled-title",
150
+ compile_js(rf'({IDENT})\("claude",({IDENT})\)\("Claude Code"\)'),
151
+ lambda m: f'{m.group(1)}("claude",{m.group(2)})("{esc}")',
152
+ )
153
+ swap(
154
+ "styled-title-padded",
155
+ compile_js(rf'({IDENT})\("claude",({IDENT})\)\(" Claude Code "\)'),
156
+ lambda m: f'{m.group(1)}("claude",{m.group(2)})(" {esc} ")',
157
+ )
158
+
159
+ return output
160
+
161
+
162
+ PATCHES = [
163
+ Patch(
164
+ id="spinner-tips",
165
+ title="Disable spinner tips",
166
+ summary="Stop the loading spinner from showing rotating tips.",
167
+ group=GROUP_CHROME,
168
+ fn=_disable_spinner_tips,
169
+ anchors=("spinnerTipsEnabled",),
170
+ ),
171
+ Patch(
172
+ id="version-marker",
173
+ title="Mark --version as patched",
174
+ summary="Append a marker line to `claude --version` (custom text via --suffix).",
175
+ group=GROUP_CHROME,
176
+ fn=_version_output,
177
+ anchors=("}.VERSION} (Claude Code)",),
178
+ ),
179
+ Patch(
180
+ id="branding",
181
+ title="Custom startup name",
182
+ summary="Rename the startup/help branding (default: your username's Code).",
183
+ group=GROUP_CHROME,
184
+ fn=_branding,
185
+ anchors=('"Welcome to Claude Code"', '{bold:!0},"Claude Code"'),
186
+ ),
187
+ ]
@@ -0,0 +1,173 @@
1
+ """Tool-call and diff rendering patches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .base import (
8
+ GROUP_OUTPUT,
9
+ IDENT,
10
+ Options,
11
+ Outcome,
12
+ Patch,
13
+ compile_js,
14
+ splice,
15
+ switch_case_end,
16
+ )
17
+
18
+ # --------------------------------------------------------------- tool calls
19
+
20
+ _COLLAPSED_RETURN = compile_js(
21
+ rf'case"collapsed_read_search":return ({IDENT})\.createElement\(({IDENT}),\{{([^}}]*)\}}\)'
22
+ )
23
+ _COLLAPSED_CALL = compile_js(
24
+ r"(?:createElement|jsx|jsxs)\("
25
+ + IDENT
26
+ + r",\{message:[^}]*inProgressToolUseIDs:[^}]*"
27
+ r"shouldAnimate:[^}]*verbose:[^,}]+,tools:[^}]*lookups:[^}]*isActiveGroup:[^}]*\}\)"
28
+ )
29
+ _VERBOSE_PROP = compile_js(r"verbose:[^,}]+")
30
+
31
+
32
+ def _tool_call_verbose(content: str, _options: Options, outcome: Outcome) -> str:
33
+ """Render collapsed read/search rows as if verbose mode were on."""
34
+
35
+ def rewrite_return(match: re.Match[str]) -> str:
36
+ ns, component, props = match.group(1), match.group(2), match.group(3)
37
+ if "verbose:" not in props:
38
+ return match.group(0)
39
+ outcome.candidates += 1
40
+ next_props = _VERBOSE_PROP.sub("verbose:!0", props, count=1)
41
+ if next_props == props:
42
+ return match.group(0)
43
+ outcome.applied += 1
44
+ return f'case"collapsed_read_search":return {ns}.createElement({component},{{{next_props}}})'
45
+
46
+ output = _COLLAPSED_RETURN.sub(rewrite_return, content)
47
+
48
+ # Newer builds use a block-form arm with a JSX-runtime call.
49
+ needle = 'case"collapsed_read_search":{'
50
+ index = 0
51
+ while True:
52
+ start = output.find(needle, index)
53
+ if start == -1:
54
+ break
55
+ end = switch_case_end(output, start + len(needle))
56
+ segment = output[start:end]
57
+ index = start + len(needle)
58
+
59
+ has_renderer = any(
60
+ tok in segment for tok in ("createElement(", "jsx(", "jsxs(")
61
+ )
62
+ if not has_renderer or "verbose:" not in segment:
63
+ continue
64
+ if not _COLLAPSED_CALL.search(segment):
65
+ continue
66
+
67
+ outcome.candidates += 1
68
+ next_segment = _VERBOSE_PROP.sub("verbose:!0", segment, count=1)
69
+ if next_segment != segment:
70
+ outcome.applied += 1
71
+ output = splice(output, start, end, next_segment)
72
+ index = start + len(next_segment)
73
+
74
+ return output
75
+
76
+
77
+ # --------------------------------------------------------------- create diff
78
+
79
+ _CREATE_RETURN = compile_js(
80
+ rf"return ({IDENT})\.(createElement|jsx|jsxs)\(({IDENT}),"
81
+ rf"\{{filePath:({IDENT}),content:({IDENT}),verbose:({IDENT})\}}\)"
82
+ )
83
+ _UPDATE_RENDERER = compile_js(
84
+ rf"(?:createElement|jsx|jsxs)\(({IDENT}),\{{filePath:[^}}]*structuredPatch:[^}}]*"
85
+ rf"style:({IDENT}),verbose:{IDENT}"
86
+ )
87
+ _LINE_COUNTER = compile_js(
88
+ rf"let {IDENT}=({IDENT})\({IDENT}\);return {IDENT}\.(?:createElement|jsxs)"
89
+ rf"\({IDENT},(?:null,|\{{children:\[)\"Wrote \""
90
+ )
91
+ _ALREADY_CREATE_DIFF = "structuredPatch:[{oldStart:1,oldLines:0,newStart:1"
92
+
93
+
94
+ def _create_diff_colors(content: str, _options: Options, outcome: Outcome) -> str:
95
+ """Render newly-created files through the diff component so lines get ``+``."""
96
+ output = content
97
+ index = 0
98
+ create_needle, update_needle = 'case"create":', 'case"update":'
99
+
100
+ while True:
101
+ create_start = output.find(create_needle, index)
102
+ if create_start == -1:
103
+ break
104
+ update_start = output.find(update_needle, create_start + len(create_needle))
105
+ if update_start == -1:
106
+ index = create_start + len(create_needle)
107
+ continue
108
+
109
+ switch_end = switch_case_end(output, update_start + len(update_needle))
110
+ create_segment = output[create_start:update_start]
111
+ update_segment = output[update_start:switch_end]
112
+ index = update_start + len(update_needle)
113
+
114
+ if _ALREADY_CREATE_DIFF in create_segment:
115
+ continue
116
+
117
+ create_match = _CREATE_RETURN.search(create_segment)
118
+ if not create_match:
119
+ continue
120
+ update_match = _UPDATE_RENDERER.search(update_segment)
121
+ if not update_match:
122
+ continue
123
+
124
+ outcome.candidates += 1
125
+ ns, factory = create_match.group(1), create_match.group(2)
126
+ file_var, content_var, verbose_var = create_match.group(4, 5, 6)
127
+ diff_renderer, style_var = update_match.group(1), update_match.group(2)
128
+
129
+ counter = _LINE_COUNTER.search(create_segment)
130
+ line_count = (
131
+ f"{counter.group(1)}({content_var})"
132
+ if counter
133
+ else f'{content_var}===""?0:{content_var}.split(`\\n`).length'
134
+ )
135
+
136
+ before = create_match.group(0)
137
+ after = (
138
+ f"return {ns}.{factory}({diff_renderer},{{"
139
+ f"filePath:{file_var},structuredPatch:[{{oldStart:1,oldLines:0,newStart:1,"
140
+ f"newLines:{line_count},"
141
+ f'lines:{content_var}===""?[]:{content_var}.split(`\\n`)'
142
+ f'.map((__cc_line)=>"+"+__cc_line)}}],'
143
+ f"firstLine:{content_var}.split(`\\n`)[0]??null,"
144
+ f'fileContent:"",style:{style_var},verbose:{verbose_var},previewHint:void 0}})'
145
+ )
146
+
147
+ next_segment = create_segment.replace(before, after, 1)
148
+ if next_segment != create_segment:
149
+ outcome.applied += 1
150
+ output = splice(output, create_start, update_start, next_segment)
151
+ index = create_start + len(next_segment)
152
+
153
+ return output
154
+
155
+
156
+ PATCHES = [
157
+ Patch(
158
+ id="tool-calls",
159
+ title="Detailed tool calls",
160
+ summary="Show full read/search tool calls instead of collapsed one-line summaries.",
161
+ group=GROUP_OUTPUT,
162
+ fn=_tool_call_verbose,
163
+ anchors=('case"collapsed_read_search"',),
164
+ ),
165
+ Patch(
166
+ id="create-diff",
167
+ title="Colour new files as diffs",
168
+ summary="Render created files through the diff view so added lines keep + and green.",
169
+ group=GROUP_OUTPUT,
170
+ fn=_create_diff_colors,
171
+ anchors=('case"create":', 'case"update":'),
172
+ ),
173
+ ]