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.
@@ -0,0 +1,5 @@
1
+ """claude-in-codex: call Claude Code from Codex for bounded, read-only critique."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("claude-in-codex")
@@ -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)