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/doctor.py ADDED
@@ -0,0 +1,116 @@
1
+ """Health checks over an installed binary and the patch set.
2
+
3
+ Two different questions, deliberately kept apart:
4
+
5
+ * **status** -- is the *installed* binary patched right now? Answered by the
6
+ manifest comment every patched bundle ends with (plus legacy fingerprints),
7
+ and the bytecode-stripped invariant.
8
+ * **dryrun** -- would our patches still apply to *this* bundle? Answered by
9
+ running every patch and reporting per-step hits, so a silently drifted
10
+ matcher shows up as a concrete "reducer.message_stop missed" instead of a
11
+ lump count.
12
+
13
+ The dry run feeds every configurable patch a synthetic configuration built
14
+ from the bundle's own discovered agents and models, so branding and the model
15
+ overrides are exercised for real instead of being exempted.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+
22
+ from .bun import Bundle
23
+ from .patcher import is_patched, read_manifest
24
+ from .patches import ALL_PATCHES, Options, Outcome, Patch
25
+ from .patches.agents import INHERIT, BuiltinAgent, discover_agents, discover_models
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class Status:
30
+ patched: bool
31
+ bytecode_stripped: bool
32
+ bytecode_size: int
33
+ #: Parsed manifest for binaries patched by this tool; ``None`` when the
34
+ #: binary is pristine or predates the manifest.
35
+ manifest: dict | None
36
+
37
+ @property
38
+ def patch_ids(self) -> list[str]:
39
+ if not self.manifest:
40
+ return []
41
+ patches = self.manifest.get("patches")
42
+ return (
43
+ [p for p in patches if isinstance(p, str)]
44
+ if isinstance(patches, list)
45
+ else []
46
+ )
47
+
48
+
49
+ def status(bundle: Bundle) -> Status:
50
+ source = bundle.source
51
+ return Status(
52
+ patched=is_patched(source),
53
+ bytecode_stripped=bundle.bytecode_size == 0,
54
+ bytecode_size=bundle.bytecode_size,
55
+ manifest=read_manifest(source),
56
+ )
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class PatchHealth:
61
+ patch: Patch
62
+ outcome: Outcome
63
+
64
+ @property
65
+ def ok(self) -> bool:
66
+ return self.outcome.landed
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class DryRun:
71
+ checks: list[PatchHealth] = field(default_factory=list)
72
+ anchors: dict[str, dict[str, int]] = field(default_factory=dict)
73
+ #: What discovery found in this bundle -- the agents and model aliases the
74
+ #: override patch would offer.
75
+ agents: list[BuiltinAgent] = field(default_factory=list)
76
+ models: list[str] = field(default_factory=list)
77
+
78
+ @property
79
+ def broken(self) -> list[PatchHealth]:
80
+ return [c for c in self.checks if not c.ok]
81
+
82
+ @property
83
+ def partial(self) -> list[PatchHealth]:
84
+ return [c for c in self.checks if c.ok and c.outcome.missed_steps()]
85
+
86
+
87
+ def _synthetic_options(agents: list[BuiltinAgent], models: list[str]) -> Options:
88
+ """A configuration that forces every configurable patch to do work.
89
+
90
+ Each discovered agent is assigned a model different from its current one,
91
+ so the rewrite (not the already-desired no-op) is what gets tested.
92
+ """
93
+ overrides = {}
94
+ for agent in agents:
95
+ target = next((m for m in models if m != agent.effective_model), None)
96
+ if target is not None:
97
+ overrides[agent.name] = target
98
+ return Options(brand="patch-cc doctor", subagent_models=overrides)
99
+
100
+
101
+ def dryrun(bundle: Bundle) -> DryRun:
102
+ """Run every patch against the bundle without writing anything."""
103
+ source = bundle.source
104
+ result = DryRun(
105
+ agents=discover_agents(source),
106
+ models=[INHERIT, *discover_models(source)],
107
+ )
108
+ options = _synthetic_options(result.agents, result.models)
109
+
110
+ for patch in ALL_PATCHES:
111
+ _, outcome = patch.run(source, options)
112
+ result.checks.append(PatchHealth(patch=patch, outcome=outcome))
113
+ if patch.anchors:
114
+ result.anchors[patch.id] = {a: source.count(a) for a in patch.anchors}
115
+
116
+ return result
patch_cc/locate.py ADDED
@@ -0,0 +1,117 @@
1
+ """Find the installed Claude Code native binary.
2
+
3
+ Only the native build is supported. The npm package no longer ships ``cli.js``
4
+ -- it is a thin wrapper that downloads the native binary -- so there is nothing
5
+ to patch in a node_modules tree anymore.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shutil
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ _VERSION_DIR = re.compile(r"^(\d+)\.(\d+)\.(\d+)")
16
+
17
+
18
+ def _version_sort_key(path: Path) -> tuple[int, int, int]:
19
+ """Sort key so 2.1.216 ranks above 2.1.9 (lexicographic order would not)."""
20
+ match = _VERSION_DIR.match(path.name)
21
+ if match is None:
22
+ return (-1, -1, -1)
23
+ major, minor, patch = match.groups()
24
+ return (int(major), int(minor), int(patch))
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class Installation:
29
+ """A resolved Claude install.
30
+
31
+ ``launcher`` is what the user runs (often a symlink); ``binary`` is the real
32
+ file we patch. When Claude is installed the canonical way they differ, and
33
+ patching ``binary`` in place means the launcher keeps working.
34
+ """
35
+
36
+ launcher: Path
37
+ binary: Path
38
+ version: str | None
39
+
40
+ @property
41
+ def is_symlinked(self) -> bool:
42
+ return self.launcher != self.binary
43
+
44
+
45
+ def _version_of(binary: Path) -> str | None:
46
+ name = binary.name
47
+ return name if _VERSION_DIR.match(name) else None
48
+
49
+
50
+ def _candidates() -> list[Path]:
51
+ home = Path.home()
52
+ found: list[Path] = []
53
+
54
+ on_path = shutil.which("claude")
55
+ if on_path:
56
+ found.append(Path(on_path))
57
+
58
+ # The default native install location, newest version first.
59
+ versions = home / ".local" / "share" / "claude" / "versions"
60
+ if versions.is_dir():
61
+ found.extend(sorted(versions.iterdir(), key=_version_sort_key, reverse=True))
62
+
63
+ for extra in (home / ".local" / "bin" / "claude", home / "bin" / "claude"):
64
+ if extra.exists():
65
+ found.append(extra)
66
+
67
+ return found
68
+
69
+
70
+ def _resolve(path: Path) -> Installation | None:
71
+ if not path.exists():
72
+ return None
73
+ real = path.resolve()
74
+ if not real.is_file():
75
+ return None
76
+ return Installation(launcher=path, binary=real, version=_version_of(real))
77
+
78
+
79
+ def find() -> Installation | None:
80
+ """Return the first resolvable installation, or ``None``."""
81
+ seen: set[Path] = set()
82
+ for candidate in _candidates():
83
+ try:
84
+ real = candidate.resolve()
85
+ except OSError:
86
+ continue
87
+ if real in seen:
88
+ continue
89
+ seen.add(real)
90
+ install = _resolve(candidate)
91
+ if install:
92
+ return install
93
+ return None
94
+
95
+
96
+ def find_or_raise() -> Installation:
97
+ install = find()
98
+ if install is None:
99
+ raise FileNotFoundError(
100
+ "Could not find a Claude Code install. Install the native build with:\n"
101
+ " curl -fsSL https://claude.ai/install.sh | bash"
102
+ )
103
+ return install
104
+
105
+
106
+ def all_versions() -> list[Installation]:
107
+ """Every version binary found under the native versions directory."""
108
+ versions = Path.home() / ".local" / "share" / "claude" / "versions"
109
+ if not versions.is_dir():
110
+ return []
111
+ out: list[Installation] = []
112
+ for entry in sorted(versions.iterdir(), key=_version_sort_key, reverse=True):
113
+ if entry.is_file():
114
+ out.append(
115
+ Installation(launcher=entry, binary=entry, version=_version_of(entry))
116
+ )
117
+ return out