claude-in-codex 0.6.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.
- claude_in_codex/__init__.py +5 -0
- claude_in_codex/claude.py +381 -0
- claude_in_codex/cli_contract.py +123 -0
- claude_in_codex/config.py +295 -0
- claude_in_codex/context.py +310 -0
- claude_in_codex/jobs.py +603 -0
- claude_in_codex/normalize.py +268 -0
- claude_in_codex/preflight.py +94 -0
- claude_in_codex/py.typed +0 -0
- claude_in_codex/schemas.py +405 -0
- claude_in_codex/server.py +2087 -0
- claude_in_codex-0.6.0.dist-info/METADATA +253 -0
- claude_in_codex-0.6.0.dist-info/RECORD +16 -0
- claude_in_codex-0.6.0.dist-info/WHEEL +4 -0
- claude_in_codex-0.6.0.dist-info/entry_points.txt +2 -0
- claude_in_codex-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Build and run the `claude` CLI invocation; classify failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
from anyio.to_thread import run_sync
|
|
16
|
+
|
|
17
|
+
from claude_in_codex import cli_contract, preflight
|
|
18
|
+
from claude_in_codex.config import (
|
|
19
|
+
INDEPENDENT_CRITIC_PROMPT,
|
|
20
|
+
access_flags,
|
|
21
|
+
config_mode_flags,
|
|
22
|
+
is_env_placeholder,
|
|
23
|
+
)
|
|
24
|
+
from claude_in_codex.schemas import ErrorInfo
|
|
25
|
+
|
|
26
|
+
_BUDGET_REPAIR = (
|
|
27
|
+
"Raise max_budget_usd or reduce context. For small prompts, try at least "
|
|
28
|
+
"$0.10-$0.20; lower best-effort budgets can spend and still stop before a "
|
|
29
|
+
"useful answer."
|
|
30
|
+
)
|
|
31
|
+
_LOGIN_MODES = frozenset({"inherit", "scoped", "safe"})
|
|
32
|
+
_LOGIN_CREDENTIAL_ENV_VARS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from claude_in_codex.preflight import FlagSupport
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ClaudeRun:
|
|
40
|
+
stdout: str
|
|
41
|
+
stderr: str
|
|
42
|
+
exit_code: int
|
|
43
|
+
elapsed_ms: int
|
|
44
|
+
timed_out: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _claude_subprocess_env(config_mode: str | None) -> dict[str, str] | None:
|
|
48
|
+
"""Return an explicit subprocess env when the selected mode must alter it.
|
|
49
|
+
|
|
50
|
+
Login-backed modes must use Claude Code's OAuth/session path, even if the
|
|
51
|
+
MCP server process was launched with stale or placeholder Anthropic direct
|
|
52
|
+
credential env vars. Bare mode deliberately relies on those credentials, so
|
|
53
|
+
leave inheritance intact.
|
|
54
|
+
"""
|
|
55
|
+
if config_mode not in _LOGIN_MODES:
|
|
56
|
+
return None
|
|
57
|
+
env = os.environ.copy()
|
|
58
|
+
for name in _LOGIN_CREDENTIAL_ENV_VARS:
|
|
59
|
+
env.pop(name, None)
|
|
60
|
+
return env
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _gate_optional(tokens: list[str], fs: FlagSupport) -> tuple[list[str], list[str]]:
|
|
64
|
+
"""Drop any HELP_GATED flag (and its value, if it takes one) the installed
|
|
65
|
+
`claude` does not advertise in --help. Returns (kept_tokens, dropped_flags).
|
|
66
|
+
ALWAYS_SEND flags are never in HELP_GATED_FLAGS, so they always survive."""
|
|
67
|
+
kept: list[str] = []
|
|
68
|
+
dropped: list[str] = []
|
|
69
|
+
i = 0
|
|
70
|
+
while i < len(tokens):
|
|
71
|
+
token = tokens[i]
|
|
72
|
+
takes_value = cli_contract.HELP_GATED_FLAGS.get(token)
|
|
73
|
+
if takes_value is not None and not preflight.is_supported(token, fs):
|
|
74
|
+
dropped.append(token)
|
|
75
|
+
i += 2 if takes_value else 1
|
|
76
|
+
continue
|
|
77
|
+
kept.append(token)
|
|
78
|
+
i += 1
|
|
79
|
+
return kept, dropped
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_command(
|
|
83
|
+
prompt: str,
|
|
84
|
+
config_mode: str,
|
|
85
|
+
access: str,
|
|
86
|
+
model: str | None,
|
|
87
|
+
max_budget_usd: float,
|
|
88
|
+
effort: str | None = None,
|
|
89
|
+
flag_support: FlagSupport | None = None,
|
|
90
|
+
) -> tuple[list[str], list[str]]:
|
|
91
|
+
"""Build the `claude` invocation. Returns (cmd, dropped_optional_flags).
|
|
92
|
+
|
|
93
|
+
Guarantee-bearing flags are sent unconditionally; HELP_GATED (depth/cosmetic)
|
|
94
|
+
flags are dropped when the installed CLI does not list them, so a minor
|
|
95
|
+
upstream change degrades instead of aborting a paid run. dropped_optional_flags
|
|
96
|
+
feeds Meta.compat_warnings."""
|
|
97
|
+
fs = flag_support if flag_support is not None else preflight.flag_support()
|
|
98
|
+
# --no-chrome disables the "Claude in Chrome" integration, which could
|
|
99
|
+
# otherwise open an interactive picker that hangs an unattended run until the
|
|
100
|
+
# timeout (burning the whole timeout and the spend) instead of answering.
|
|
101
|
+
tokens = [cli_contract.CLAUDE_BIN, *cli_contract.CORE_INVOCATION, "--no-chrome"]
|
|
102
|
+
tokens += config_mode_flags(config_mode)
|
|
103
|
+
tokens += access_flags(access)
|
|
104
|
+
tokens += ["--append-system-prompt", INDEPENDENT_CRITIC_PROMPT]
|
|
105
|
+
tokens += ["--max-budget-usd", f"{max_budget_usd}"]
|
|
106
|
+
if effort and effort in cli_contract.VALID_EFFORTS:
|
|
107
|
+
tokens += ["--effort", effort]
|
|
108
|
+
if model:
|
|
109
|
+
tokens += ["--model", model]
|
|
110
|
+
cmd, dropped = _gate_optional(tokens, fs)
|
|
111
|
+
# The prompt is supplied over stdin by the runner. Keeping it out of argv
|
|
112
|
+
# avoids exposing gathered diffs/context through local process listings.
|
|
113
|
+
_ = prompt
|
|
114
|
+
return cmd, dropped
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def auth_status(
|
|
118
|
+
timeout_seconds: int = 10, *, config_mode: str | None
|
|
119
|
+
) -> tuple[bool | None, str | None]:
|
|
120
|
+
"""Probe `claude auth status` without making a paid call.
|
|
121
|
+
|
|
122
|
+
Returns (logged_in, detail). logged_in is None when the probe could not run
|
|
123
|
+
(claude missing, timeout) so callers can report 'unknown' rather than a
|
|
124
|
+
misleading False. detail is a NON-identifying phrase, never the raw CLI output:
|
|
125
|
+
`claude auth status` prints the account email and organization, which would leak
|
|
126
|
+
into shared logs/transcripts. The boolean already carries the machine-readable
|
|
127
|
+
truth, so we deliberately drop the raw text."""
|
|
128
|
+
try:
|
|
129
|
+
proc = subprocess.run(
|
|
130
|
+
[cli_contract.CLAUDE_BIN, *cli_contract.AUTH_STATUS_ARGS],
|
|
131
|
+
capture_output=True,
|
|
132
|
+
text=True,
|
|
133
|
+
timeout=timeout_seconds,
|
|
134
|
+
check=False,
|
|
135
|
+
env=_claude_subprocess_env(config_mode),
|
|
136
|
+
)
|
|
137
|
+
except (OSError, subprocess.SubprocessError):
|
|
138
|
+
return None, None
|
|
139
|
+
logged_in = proc.returncode == 0
|
|
140
|
+
detail = (
|
|
141
|
+
"Claude CLI reports an authenticated session."
|
|
142
|
+
if logged_in
|
|
143
|
+
else "Claude CLI reports no authenticated session; run `claude /login`."
|
|
144
|
+
)
|
|
145
|
+
return logged_in, detail
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _kill_process_tree(proc: subprocess.Popen) -> None:
|
|
149
|
+
"""Best-effort terminate the process and its children. POSIX: kill the
|
|
150
|
+
process group (the child is its own session leader). Falls back to killing
|
|
151
|
+
just the process where process groups are unavailable (e.g. Windows)."""
|
|
152
|
+
if proc.poll() is not None:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
if hasattr(os, "killpg"):
|
|
156
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
157
|
+
else: # pragma: no cover - non-POSIX fallback
|
|
158
|
+
proc.kill()
|
|
159
|
+
except (ProcessLookupError, PermissionError):
|
|
160
|
+
with contextlib.suppress(ProcessLookupError):
|
|
161
|
+
proc.kill()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def run_claude_async(
|
|
165
|
+
cmd: list[str],
|
|
166
|
+
cwd: str,
|
|
167
|
+
timeout_seconds: int,
|
|
168
|
+
stdin_text: str | None = None,
|
|
169
|
+
*,
|
|
170
|
+
config_mode: str,
|
|
171
|
+
) -> ClaudeRun:
|
|
172
|
+
"""Run `claude` as a subprocess, returning a ClaudeRun.
|
|
173
|
+
|
|
174
|
+
The subprocess is started in its own session (process group) so that, on a
|
|
175
|
+
timeout OR an MCP request cancellation, we can terminate the whole tree
|
|
176
|
+
rather than orphaning a paid Claude run."""
|
|
177
|
+
start = time.monotonic()
|
|
178
|
+
try:
|
|
179
|
+
proc = subprocess.Popen(
|
|
180
|
+
cmd,
|
|
181
|
+
cwd=cwd,
|
|
182
|
+
stdin=subprocess.PIPE if stdin_text is not None else None,
|
|
183
|
+
stdout=subprocess.PIPE,
|
|
184
|
+
stderr=subprocess.PIPE,
|
|
185
|
+
text=True,
|
|
186
|
+
encoding="utf-8",
|
|
187
|
+
env=_claude_subprocess_env(config_mode),
|
|
188
|
+
start_new_session=True,
|
|
189
|
+
)
|
|
190
|
+
except OSError:
|
|
191
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
192
|
+
return ClaudeRun("", "claude_not_found", 127, elapsed, False)
|
|
193
|
+
|
|
194
|
+
def _wait() -> tuple[str, str, bool]:
|
|
195
|
+
try:
|
|
196
|
+
out, err = proc.communicate(input=stdin_text, timeout=timeout_seconds)
|
|
197
|
+
return out, err, False
|
|
198
|
+
except subprocess.TimeoutExpired:
|
|
199
|
+
_kill_process_tree(proc)
|
|
200
|
+
out, err = proc.communicate()
|
|
201
|
+
return out, err, True
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
out, err, timed_out = await run_sync(_wait, abandon_on_cancel=True)
|
|
205
|
+
except anyio.get_cancelled_exc_class():
|
|
206
|
+
_kill_process_tree(proc)
|
|
207
|
+
raise
|
|
208
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
209
|
+
if timed_out:
|
|
210
|
+
return ClaudeRun("", "timeout", -9, elapsed, True)
|
|
211
|
+
return ClaudeRun(out, err, proc.returncode, elapsed, False)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _auth_repair_for(config_mode: str | None) -> str:
|
|
215
|
+
if config_mode in _LOGIN_MODES:
|
|
216
|
+
return "Run `claude /login`; the attempted config_mode uses the Claude login path."
|
|
217
|
+
if config_mode == "bare":
|
|
218
|
+
return (
|
|
219
|
+
"Set a valid ANTHROPIC_API_KEY, or use config_mode inherit/scoped/safe "
|
|
220
|
+
"after `claude /login`."
|
|
221
|
+
)
|
|
222
|
+
return "Run `claude /login`, or set a valid ANTHROPIC_API_KEY for config_mode=bare."
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _api_key_repair_for(config_mode: str | None) -> str:
|
|
226
|
+
# A literal `${ANTHROPIC_API_KEY}` is the host failing to expand env vars, not a
|
|
227
|
+
# rotated/revoked key — point at host substitution instead of "set a valid key".
|
|
228
|
+
if is_env_placeholder(os.environ.get("ANTHROPIC_API_KEY")):
|
|
229
|
+
return (
|
|
230
|
+
"ANTHROPIC_API_KEY is a literal ${...} placeholder; your MCP host is not "
|
|
231
|
+
"expanding env substitutions. Use an env_vars passthrough list, or set a "
|
|
232
|
+
"literal key."
|
|
233
|
+
)
|
|
234
|
+
if config_mode == "bare":
|
|
235
|
+
return (
|
|
236
|
+
"Set a valid ANTHROPIC_API_KEY, or use config_mode inherit/scoped/safe "
|
|
237
|
+
"after `claude /login`."
|
|
238
|
+
)
|
|
239
|
+
if config_mode in _LOGIN_MODES:
|
|
240
|
+
return (
|
|
241
|
+
"The attempted config_mode does not rely on ANTHROPIC_API_KEY; unset or fix "
|
|
242
|
+
"ANTHROPIC_API_KEY, then rerun claude_status before retrying."
|
|
243
|
+
)
|
|
244
|
+
return (
|
|
245
|
+
"Set a valid ANTHROPIC_API_KEY, or use config_mode inherit/scoped/safe "
|
|
246
|
+
"after `claude /login`."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _has_logged_out_signal(blob: str) -> bool:
|
|
251
|
+
# Narrow on purpose: a bare "/login" can appear in reviewed content or URLs
|
|
252
|
+
# echoed by Claude, so require the explicit prompt wording.
|
|
253
|
+
return "not logged in" in blob or "please run /login" in blob
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _has_invalid_api_key_signal(blob: str) -> bool:
|
|
257
|
+
return (
|
|
258
|
+
"api_key_invalid" in blob
|
|
259
|
+
or "invalid api key" in blob
|
|
260
|
+
or "anthropic_api_key is invalid" in blob
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def classify_failure(run: ClaudeRun, *, config_mode: str | None = None) -> ErrorInfo:
|
|
265
|
+
env = None
|
|
266
|
+
with contextlib.suppress(json.JSONDecodeError, ValueError, TypeError):
|
|
267
|
+
env = json.loads(run.stdout)
|
|
268
|
+
if run.stderr == "claude_not_found":
|
|
269
|
+
return ErrorInfo(
|
|
270
|
+
code="claude_not_found",
|
|
271
|
+
message="The `claude` CLI was not found on PATH.",
|
|
272
|
+
repair="Install Claude Code and ensure `claude` is on PATH.",
|
|
273
|
+
)
|
|
274
|
+
if run.timed_out:
|
|
275
|
+
return ErrorInfo(
|
|
276
|
+
code="timeout",
|
|
277
|
+
message="claude exceeded the timeout.",
|
|
278
|
+
repair="Narrow the scope/focus or raise timeout_seconds.",
|
|
279
|
+
retryable=True,
|
|
280
|
+
)
|
|
281
|
+
if isinstance(env, dict) and (
|
|
282
|
+
env.get("is_error") or env.get("subtype") not in cli_contract.SUCCESS_SUBTYPES
|
|
283
|
+
):
|
|
284
|
+
subtype = str(env.get("subtype") or "").lower()
|
|
285
|
+
result = str(env.get("result") or "")
|
|
286
|
+
structured_blob = f"{subtype}\n{result}".lower()
|
|
287
|
+
combined_blob = f"{structured_blob}\n{run.stderr}".lower()
|
|
288
|
+
if _has_logged_out_signal(combined_blob):
|
|
289
|
+
return ErrorInfo(
|
|
290
|
+
code="claude_auth_required",
|
|
291
|
+
message="claude is not authenticated.",
|
|
292
|
+
repair=_auth_repair_for(config_mode),
|
|
293
|
+
)
|
|
294
|
+
if _has_invalid_api_key_signal(structured_blob):
|
|
295
|
+
return ErrorInfo(
|
|
296
|
+
code="api_key_invalid",
|
|
297
|
+
message="ANTHROPIC_API_KEY is invalid.",
|
|
298
|
+
repair=_api_key_repair_for(config_mode),
|
|
299
|
+
)
|
|
300
|
+
if "auth" in structured_blob or "login" in structured_blob:
|
|
301
|
+
return ErrorInfo(
|
|
302
|
+
code="claude_auth_required",
|
|
303
|
+
message="claude is not authenticated.",
|
|
304
|
+
repair=_auth_repair_for(config_mode),
|
|
305
|
+
)
|
|
306
|
+
if "budget" in structured_blob:
|
|
307
|
+
return ErrorInfo(
|
|
308
|
+
code="budget_exceeded",
|
|
309
|
+
message="claude reached the max-budget stop threshold "
|
|
310
|
+
"(a best-effort limit, not a hard cap).",
|
|
311
|
+
repair=_BUDGET_REPAIR,
|
|
312
|
+
retryable=True,
|
|
313
|
+
)
|
|
314
|
+
if "permission" in structured_blob or "denied" in structured_blob:
|
|
315
|
+
return ErrorInfo(
|
|
316
|
+
code="claude_permission_error",
|
|
317
|
+
message="claude was denied a requested permission.",
|
|
318
|
+
repair="Use access=toolless, or allow the needed read-only tools.",
|
|
319
|
+
)
|
|
320
|
+
if "rate" in structured_blob or "overloaded" in structured_blob:
|
|
321
|
+
return ErrorInfo(
|
|
322
|
+
code="nonzero_exit",
|
|
323
|
+
message=f"claude reported a retryable error: {result[:200]}",
|
|
324
|
+
repair="Retry later, or reduce request size.",
|
|
325
|
+
retryable=True,
|
|
326
|
+
)
|
|
327
|
+
if cli_contract.is_contract_drift(result, subtype):
|
|
328
|
+
return contract_changed_error()
|
|
329
|
+
detail = result.strip() or subtype or "unknown error"
|
|
330
|
+
return ErrorInfo(
|
|
331
|
+
code="nonzero_exit",
|
|
332
|
+
message=f"claude reported an error: {detail[:200]}",
|
|
333
|
+
repair="Inspect the error; retry with a smaller or corrected request.",
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
extra = ""
|
|
337
|
+
if isinstance(env, dict):
|
|
338
|
+
extra = f"{env.get('subtype', '')} {env.get('result', '')}"
|
|
339
|
+
blob = f"{extra}\n{run.stdout}\n{run.stderr}".lower()
|
|
340
|
+
if _has_logged_out_signal(blob):
|
|
341
|
+
return ErrorInfo(
|
|
342
|
+
code="claude_auth_required",
|
|
343
|
+
message="claude is not authenticated.",
|
|
344
|
+
repair=_auth_repair_for(config_mode),
|
|
345
|
+
)
|
|
346
|
+
if _has_invalid_api_key_signal(blob):
|
|
347
|
+
return ErrorInfo(
|
|
348
|
+
code="api_key_invalid",
|
|
349
|
+
message="ANTHROPIC_API_KEY is invalid.",
|
|
350
|
+
repair=_api_key_repair_for(config_mode),
|
|
351
|
+
)
|
|
352
|
+
if "budget" in blob:
|
|
353
|
+
return ErrorInfo(
|
|
354
|
+
code="budget_exceeded",
|
|
355
|
+
message="claude reached the max-budget stop threshold "
|
|
356
|
+
"(a best-effort limit, not a hard cap).",
|
|
357
|
+
repair=_BUDGET_REPAIR,
|
|
358
|
+
retryable=True,
|
|
359
|
+
)
|
|
360
|
+
# An unknown flag / invalid value means the CLI contract drifted from what this
|
|
361
|
+
# plugin sends. Check last so an auth/budget message is never misread as drift.
|
|
362
|
+
if cli_contract.is_contract_drift(run.stderr, run.stdout):
|
|
363
|
+
return contract_changed_error()
|
|
364
|
+
return ErrorInfo(
|
|
365
|
+
code="nonzero_exit",
|
|
366
|
+
message=f"claude exited {run.exit_code}: {run.stderr.strip()[:200]}",
|
|
367
|
+
repair="Inspect the error; retry with a smaller request.",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def contract_changed_error() -> ErrorInfo:
|
|
372
|
+
"""Shared cli_contract_changed error, reused across every failure path so a
|
|
373
|
+
drift is reported identically whether it surfaces on the sync, envelope, or
|
|
374
|
+
async-job path."""
|
|
375
|
+
return ErrorInfo(
|
|
376
|
+
code="cli_contract_changed",
|
|
377
|
+
message="claude rejected a flag or value this plugin sent — its CLI "
|
|
378
|
+
"contract likely changed for your installed version.",
|
|
379
|
+
repair="Update claude-in-codex (or pin claude to a supported version); "
|
|
380
|
+
"run claude_status to check the version.",
|
|
381
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Single source of truth for the external `claude` CLI contract.
|
|
2
|
+
|
|
3
|
+
Every assumption this server makes about the `claude` CLI — its flags,
|
|
4
|
+
subcommands, JSON-envelope keys, accepted effort levels, supported major
|
|
5
|
+
versions, and the stderr phrasings that mean the contract drifted — lives here so
|
|
6
|
+
an upstream breaking change is a one-file, greppable, testable edit. See
|
|
7
|
+
COMPATIBILITY.md for the assumption -> upstream-source map.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
CLAUDE_BIN = "claude"
|
|
13
|
+
|
|
14
|
+
# Core invocation that CANNOT be dropped: -p (print mode) + JSON output. If these
|
|
15
|
+
# disappear upstream the server cannot function, so a run must fail loudly rather
|
|
16
|
+
# than silently degrade.
|
|
17
|
+
CORE_INVOCATION = ("-p", "--output-format", "json")
|
|
18
|
+
END_OF_OPTIONS = "--"
|
|
19
|
+
|
|
20
|
+
# Subcommands / probes (free; no paid call).
|
|
21
|
+
VERSION_ARGS = ("--version",)
|
|
22
|
+
AUTH_STATUS_ARGS = ("auth", "status", "--text")
|
|
23
|
+
HELP_ARGS = ("--help",)
|
|
24
|
+
|
|
25
|
+
# --- Flag classes (see Item 5 of the resilience plan / COMPATIBILITY.md) --------
|
|
26
|
+
# ALWAYS_SEND: guarantee-bearing flags, sent unconditionally and NEVER gated on
|
|
27
|
+
# `--help` parsing. If upstream removes/renames one, `claude` rejects it at
|
|
28
|
+
# arg-parse BEFORE any model call (zero spend) and classify_failure() labels it
|
|
29
|
+
# cli_contract_changed. Gating these on the (inherently fuzzy) --help parse could
|
|
30
|
+
# silently drop a security/cost/behavioral guarantee, so we never do. All are long
|
|
31
|
+
# flags (the diagnostic in claude_status checks them against parsed --help).
|
|
32
|
+
ALWAYS_SEND_FLAGS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"--output-format", # core JSON output
|
|
35
|
+
"--no-chrome", # no interactive picker hanging an unattended run
|
|
36
|
+
"--append-system-prompt", # the independent-critic guardrails
|
|
37
|
+
"--max-budget-usd", # best-effort spend stop threshold
|
|
38
|
+
"--no-session-persistence", # avoid storing sensitive review prompts/results on disk
|
|
39
|
+
"--tools", # read-only / no-tool guarantee
|
|
40
|
+
"--strict-mcp-config",
|
|
41
|
+
"--mcp-config", # strip the user's MCP fleet (security boundary)
|
|
42
|
+
"--setting-sources", # scoped-mode isolation
|
|
43
|
+
"--bare", # bare-mode isolation
|
|
44
|
+
"--safe-mode", # OAuth-preserving customization/hook isolation
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# HELP_GATED: dropping one only reduces depth or relies on a still-present primary
|
|
49
|
+
# guard — never a safety/cost regression. The value is whether the flag takes an
|
|
50
|
+
# argument (so the gate skips the value token too). These are the ONLY flags gated
|
|
51
|
+
# on `claude --help`; a false negative here merely drops a harmless flag.
|
|
52
|
+
HELP_GATED_FLAGS = {
|
|
53
|
+
"--effort": True, # reasoning depth only
|
|
54
|
+
"--model": True, # falls back to the configured default model
|
|
55
|
+
"--disallowed-tools": True, # defense-in-depth; --tools is the primary allowlist
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Cache TTL for the `claude --help` probe, so a long-lived server re-probes after
|
|
59
|
+
# an in-place CLI upgrade instead of trusting a stale snapshot forever.
|
|
60
|
+
HELP_CACHE_TTL_SECONDS = 300
|
|
61
|
+
|
|
62
|
+
# --- Reasoning effort -----------------------------------------------------------
|
|
63
|
+
VALID_EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
64
|
+
DEFAULT_EFFORT = "xhigh"
|
|
65
|
+
|
|
66
|
+
# --- Supported `claude` major version(s) ----------------------------------------
|
|
67
|
+
# A set (not a single int) so a future major can be added without a code change,
|
|
68
|
+
# and overridable via env so a user can opt into an untested major themselves.
|
|
69
|
+
SUPPORTED_MAJORS = frozenset({2})
|
|
70
|
+
SUPPORTED_MAJORS_ENV = "CLAUDE_IN_CODEX_SUPPORTED_MAJORS"
|
|
71
|
+
|
|
72
|
+
# --- JSON envelope keys read from `claude -p --output-format json` ---------------
|
|
73
|
+
# normalize.py / apply_cost_usage parse these tolerantly with .get(); listing them
|
|
74
|
+
# here keeps the consumed surface greppable and gives the golden-envelope test a
|
|
75
|
+
# canonical reference.
|
|
76
|
+
SUCCESS_SUBTYPES = (None, "success")
|
|
77
|
+
ENVELOPE_KEYS = frozenset(
|
|
78
|
+
{
|
|
79
|
+
"is_error",
|
|
80
|
+
"subtype",
|
|
81
|
+
"result",
|
|
82
|
+
"total_cost_usd",
|
|
83
|
+
"usage",
|
|
84
|
+
"session_id",
|
|
85
|
+
"modelUsage",
|
|
86
|
+
"permission_denials",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
USAGE_KEYS = frozenset(
|
|
90
|
+
{
|
|
91
|
+
"input_tokens",
|
|
92
|
+
"output_tokens",
|
|
93
|
+
"cache_read_input_tokens",
|
|
94
|
+
"cache_creation_input_tokens",
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# --- Contract-drift stderr signatures -------------------------------------------
|
|
99
|
+
# Phrasings a CLI prints when it rejects a flag or value we sent. Matching any
|
|
100
|
+
# (case-insensitive) reclassifies an otherwise-generic failure as
|
|
101
|
+
# cli_contract_changed, telling the user the plugin needs an update for their CLI
|
|
102
|
+
# rather than leaving a confusing nonzero_exit.
|
|
103
|
+
CONTRACT_DRIFT_STDERR_PATTERNS = (
|
|
104
|
+
"unknown option",
|
|
105
|
+
"unknown flag",
|
|
106
|
+
"unknown argument",
|
|
107
|
+
"unrecognized option",
|
|
108
|
+
"unrecognized argument",
|
|
109
|
+
"no such option",
|
|
110
|
+
"invalid choice",
|
|
111
|
+
"invalid value",
|
|
112
|
+
"unexpected argument",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_contract_drift(*texts: str | None) -> bool:
|
|
117
|
+
"""Whether any provided text carries a contract-drift signature.
|
|
118
|
+
|
|
119
|
+
Used on every failure path (sync classify_failure, the zero-exit is_error
|
|
120
|
+
envelope, and the async job error) so drift is labelled consistently no matter
|
|
121
|
+
where `claude` surfaces it."""
|
|
122
|
+
blob = "\n".join(t for t in texts if t).lower()
|
|
123
|
+
return any(pattern in blob for pattern in CONTRACT_DRIFT_STDERR_PATTERNS)
|