cc-plugin-codex 0.1.4__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.
- cc_plugin_codex/__init__.py +5 -0
- cc_plugin_codex/claude.py +284 -0
- cc_plugin_codex/cli_contract.py +122 -0
- cc_plugin_codex/config.py +172 -0
- cc_plugin_codex/context.py +210 -0
- cc_plugin_codex/jobs.py +561 -0
- cc_plugin_codex/normalize.py +243 -0
- cc_plugin_codex/preflight.py +94 -0
- cc_plugin_codex/py.typed +0 -0
- cc_plugin_codex/schemas.py +344 -0
- cc_plugin_codex/server.py +1656 -0
- cc_plugin_codex-0.1.4.dist-info/METADATA +223 -0
- cc_plugin_codex-0.1.4.dist-info/RECORD +16 -0
- cc_plugin_codex-0.1.4.dist-info/WHEEL +4 -0
- cc_plugin_codex-0.1.4.dist-info/entry_points.txt +2 -0
- cc_plugin_codex-0.1.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Gather git diff context for review. Claude never runs git itself."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from cc_plugin_codex.config import git_timeout_seconds
|
|
11
|
+
from cc_plugin_codex.schemas import ContextSummary
|
|
12
|
+
|
|
13
|
+
MAX_DIFF_BYTES = 200_000
|
|
14
|
+
|
|
15
|
+
_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidScopeError(ValueError):
|
|
19
|
+
"""Raised when the requested diff scope is not recognized."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidBaseError(ValueError):
|
|
23
|
+
"""Raised when the base ref for scope=branch is malformed/unsafe."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _valid_ref(ref: str) -> bool:
|
|
27
|
+
"""A conservative git ref/commit check: no leading dash, no option/shell chars."""
|
|
28
|
+
return bool(ref) and not ref.startswith("-") and bool(_REF_RE.match(ref))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SECRET_PATH_RE = re.compile(
|
|
32
|
+
r"(^|/)(\.env(\.|$)|.*\.env$|.*\.pem$|.*\.key$|id_rsa|id_ed25519|.*\.p12$)",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
SECRET_VALUE_PATTERNS = [
|
|
37
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
38
|
+
re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"),
|
|
39
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9-]{20,}"),
|
|
40
|
+
re.compile(r"(?i)(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/=-]{16,}"),
|
|
41
|
+
re.compile(
|
|
42
|
+
r"(?i)((?:api|access|secret|private)?_?(?:key|token|secret)\s*[:=]\s*['\"]?)[A-Za-z0-9._~+/=-]{16,}"
|
|
43
|
+
),
|
|
44
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ContextResult:
|
|
50
|
+
text: str
|
|
51
|
+
summary: ContextSummary
|
|
52
|
+
truncated: bool = False
|
|
53
|
+
truncation_hint: str | None = None
|
|
54
|
+
redacted_paths: list[str] = field(default_factory=list)
|
|
55
|
+
diff_bytes: int = 0 # full (pre-truncation) UTF-8 byte size of the redacted diff
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _git(cwd: str, *args: str) -> str:
|
|
59
|
+
timeout = git_timeout_seconds()
|
|
60
|
+
try:
|
|
61
|
+
proc = subprocess.run(
|
|
62
|
+
["git", *args],
|
|
63
|
+
cwd=cwd,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
check=False,
|
|
68
|
+
)
|
|
69
|
+
except subprocess.TimeoutExpired as exc:
|
|
70
|
+
raise RuntimeError(f"git {' '.join(args)} timed out after {timeout}s") from exc
|
|
71
|
+
if proc.returncode != 0:
|
|
72
|
+
raise RuntimeError(proc.stderr.strip() or "git failed")
|
|
73
|
+
return proc.stdout
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _base_exists(cwd: str, base: str) -> bool:
|
|
77
|
+
"""Whether base resolves to a commit.
|
|
78
|
+
|
|
79
|
+
Syntactically safe but nonexistent refs should be reported as invalid_base,
|
|
80
|
+
not as a generic git/internal failure. This keeps branch-diff tools
|
|
81
|
+
repairable for agents.
|
|
82
|
+
"""
|
|
83
|
+
timeout = git_timeout_seconds()
|
|
84
|
+
try:
|
|
85
|
+
proc = subprocess.run(
|
|
86
|
+
["git", "rev-parse", "--verify", "--quiet", f"{base}^{{commit}}"],
|
|
87
|
+
cwd=cwd,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
check=False,
|
|
92
|
+
)
|
|
93
|
+
except subprocess.TimeoutExpired as exc:
|
|
94
|
+
raise RuntimeError(f"git rev-parse timed out after {timeout}s") from exc
|
|
95
|
+
return proc.returncode == 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _diff_args(scope: str, base: str) -> list[str]:
|
|
99
|
+
# --no-ext-diff + --no-textconv prevent configured external/textconv diff drivers
|
|
100
|
+
# from executing commands during our own git call.
|
|
101
|
+
common = ["diff", "--no-ext-diff", "--no-textconv"]
|
|
102
|
+
if scope == "working_tree":
|
|
103
|
+
return common
|
|
104
|
+
if scope == "staged":
|
|
105
|
+
return [*common, "--cached"]
|
|
106
|
+
if scope == "branch":
|
|
107
|
+
if not _valid_ref(base):
|
|
108
|
+
raise InvalidBaseError(f"invalid base ref: {base!r}")
|
|
109
|
+
# --end-of-options ensures the ref can never be parsed as a git option.
|
|
110
|
+
return [*common, "--end-of-options", f"{base}...HEAD"]
|
|
111
|
+
raise InvalidScopeError(f"invalid scope: {scope}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _summary(cwd: str, diff_args: list[str]) -> ContextSummary:
|
|
115
|
+
numstat = _git(cwd, *diff_args, "--numstat")
|
|
116
|
+
files = added = removed = 0
|
|
117
|
+
for line in numstat.splitlines():
|
|
118
|
+
parts = line.split("\t")
|
|
119
|
+
if len(parts) != 3:
|
|
120
|
+
continue
|
|
121
|
+
files += 1
|
|
122
|
+
if parts[0].isdigit():
|
|
123
|
+
added += int(parts[0])
|
|
124
|
+
if parts[1].isdigit():
|
|
125
|
+
removed += int(parts[1])
|
|
126
|
+
return ContextSummary(files_changed=files, lines_added=added, lines_removed=removed)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _diff_path_from_header(line: str) -> str:
|
|
130
|
+
spec = line[len("diff --git ") :]
|
|
131
|
+
try:
|
|
132
|
+
parts = shlex.split(spec)
|
|
133
|
+
except ValueError:
|
|
134
|
+
parts = spec.split()
|
|
135
|
+
if len(parts) >= 2:
|
|
136
|
+
path = parts[1]
|
|
137
|
+
return path[2:] if path.startswith("b/") else path
|
|
138
|
+
return spec
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _redact_secret_values(line: str) -> tuple[str, bool]:
|
|
142
|
+
redacted = False
|
|
143
|
+
out = line
|
|
144
|
+
for pattern in SECRET_VALUE_PATTERNS:
|
|
145
|
+
|
|
146
|
+
def repl(match: re.Match) -> str:
|
|
147
|
+
nonlocal redacted
|
|
148
|
+
redacted = True
|
|
149
|
+
if match.lastindex:
|
|
150
|
+
return f"{match.group(1)}[redacted: secret value]"
|
|
151
|
+
return "[redacted: secret value]"
|
|
152
|
+
|
|
153
|
+
out = pattern.sub(repl, out)
|
|
154
|
+
return out, redacted
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _redact(diff: str) -> tuple[str, list[str]]:
|
|
158
|
+
out_lines: list[str] = []
|
|
159
|
+
redacted: list[str] = []
|
|
160
|
+
skipping = False
|
|
161
|
+
current_path = ""
|
|
162
|
+
for line in diff.splitlines():
|
|
163
|
+
if line.startswith("diff --git "):
|
|
164
|
+
spec = line[len("diff --git ") :] # "a/<path> b/<path>" (paths may be quoted)
|
|
165
|
+
current_path = _diff_path_from_header(line)
|
|
166
|
+
skipping = bool(SECRET_PATH_RE.search(spec))
|
|
167
|
+
if skipping:
|
|
168
|
+
redacted.append(current_path or spec)
|
|
169
|
+
out_lines.append(line) # keep the real header so reviewers see the file
|
|
170
|
+
out_lines.append("[redacted: secret-looking file not sent]")
|
|
171
|
+
continue
|
|
172
|
+
if not skipping:
|
|
173
|
+
scan_line = (
|
|
174
|
+
line.startswith(("+", "-", " ")) and not line.startswith(("+++", "---"))
|
|
175
|
+
) or line.startswith("Authorization:")
|
|
176
|
+
emit = line
|
|
177
|
+
if scan_line:
|
|
178
|
+
emit, changed = _redact_secret_values(line)
|
|
179
|
+
if changed and current_path and current_path not in redacted:
|
|
180
|
+
redacted.append(current_path)
|
|
181
|
+
out_lines.append(emit)
|
|
182
|
+
return "\n".join(out_lines), redacted
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def gather_context(cwd: str, scope: str, base: str) -> ContextResult:
|
|
186
|
+
diff_args = _diff_args(scope, base) # raises InvalidScopeError / InvalidBaseError
|
|
187
|
+
if scope == "branch" and not _base_exists(cwd, base):
|
|
188
|
+
raise InvalidBaseError(f"base ref does not resolve to a commit: {base!r}")
|
|
189
|
+
summary = _summary(cwd, diff_args)
|
|
190
|
+
raw = _git(cwd, *diff_args)
|
|
191
|
+
text, redacted = _redact(raw)
|
|
192
|
+
truncated = False
|
|
193
|
+
hint = None
|
|
194
|
+
encoded = text.encode("utf-8", "replace")
|
|
195
|
+
diff_bytes = len(encoded) # the true size, reported even when we truncate below
|
|
196
|
+
if diff_bytes > MAX_DIFF_BYTES:
|
|
197
|
+
text = encoded[:MAX_DIFF_BYTES].decode("utf-8", "ignore")
|
|
198
|
+
truncated = True
|
|
199
|
+
hint = (
|
|
200
|
+
f"diff exceeded {MAX_DIFF_BYTES} bytes; use scope=staged, choose "
|
|
201
|
+
"a closer branch base, or call claude_ask with selected context"
|
|
202
|
+
)
|
|
203
|
+
return ContextResult(
|
|
204
|
+
text=text,
|
|
205
|
+
summary=summary,
|
|
206
|
+
truncated=truncated,
|
|
207
|
+
truncation_hint=hint,
|
|
208
|
+
redacted_paths=redacted,
|
|
209
|
+
diff_bytes=diff_bytes,
|
|
210
|
+
)
|