Skip to content

Commit eec302b

Browse files
committed
feat(llm_cli): integrate Claude Code CLI as LLM_PROVIDER
1 parent db65347 commit eec302b

7 files changed

Lines changed: 666 additions & 4 deletions

File tree

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@
1212
# --- Most important ---------------------------------------------------------
1313

1414
# Provider used for LLM calls. Common values: anthropic, openai, openrouter,
15-
# gemini, nvidia, codex.
15+
# gemini, nvidia, codex, claude-code.
1616
LLM_PROVIDER=anthropic
1717

1818
# Codex CLI works for `opensre investigate` after `codex login`.
1919
# Leave CODEX_MODEL empty to use the CLI's currently configured model.
2020
CODEX_MODEL=
2121
CODEX_BIN=
2222

23+
# Claude Code CLI works for `opensre investigate` after `claude login` or setting ANTHROPIC_API_KEY.
24+
# Install: npm i -g @anthropic-ai/claude-code
25+
# Leave CLAUDE_CODE_MODEL empty to use the CLI's currently configured model.
26+
CLAUDE_CODE_MODEL=
27+
CLAUDE_CODE_BIN=
28+
2329
# Set the key for the provider you choose above.
2430
ANTHROPIC_API_KEY=
2531
ANTHROPIC_REASONING_MODEL=

app/cli/wizard/config.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@ class ProviderOption:
132132
ModelOption(value="qwen2.5:7b", label="Qwen 2.5 (7B)"),
133133
)
134134

135+
# Empty value means "no --model" so Claude Code uses its configured default.
136+
CLAUDE_CODE_MODELS = (
137+
ModelOption(
138+
value="",
139+
label="CLI default (no --model; use Claude Code configured model)",
140+
),
141+
ModelOption(value="claude-opus-4-7", label="Claude Opus 4.7 — most capable"),
142+
ModelOption(value="claude-sonnet-4-6", label="Claude Sonnet 4.6 — balanced"),
143+
ModelOption(value="claude-haiku-4-5-20251001", label="Claude Haiku 4.5 — fast, cost-efficient"),
144+
)
145+
135146
# Empty value means "no -m" so the Codex CLI uses its configured default/current model.
136147
CODEX_MODELS = (
137148
ModelOption(
@@ -157,6 +168,12 @@ def _codex_adapter_factory() -> LLMCLIAdapter:
157168
return CodexAdapter()
158169

159170

171+
def _claude_code_adapter_factory() -> LLMCLIAdapter:
172+
from app.integrations.llm_cli.claude_code import ClaudeCodeAdapter
173+
174+
return ClaudeCodeAdapter()
175+
176+
160177
SUPPORTED_PROVIDERS = (
161178
ProviderOption(
162179
value="anthropic",
@@ -220,6 +237,18 @@ def _codex_adapter_factory() -> LLMCLIAdapter:
220237
credential_secret=False,
221238
adapter_factory=_codex_adapter_factory,
222239
),
240+
ProviderOption(
241+
value="claude-code",
242+
label="Anthropic Claude Code CLI",
243+
group="Local CLI providers",
244+
api_key_env="",
245+
model_env="CLAUDE_CODE_MODEL",
246+
default_model="",
247+
models=CLAUDE_CODE_MODELS,
248+
credential_kind="cli",
249+
credential_secret=False,
250+
adapter_factory=_claude_code_adapter_factory,
251+
),
223252
ProviderOption(
224253
value="ollama",
225254
label="Ollama (local)",

app/config.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def get_environment() -> Environment:
122122
"bedrock",
123123
"minimax",
124124
"codex",
125+
"claude-code",
125126
]
126127

127128

@@ -167,6 +168,7 @@ def _normalize_provider(cls, value: object) -> str:
167168
"bedrock",
168169
"minimax",
169170
"codex",
171+
"claude-code",
170172
)
171173
if provider in valid_providers:
172174
return provider
@@ -181,8 +183,8 @@ def _normalize_provider(cls, value: object) -> str:
181183

182184
@model_validator(mode="after")
183185
def _require_api_key_for_selected_provider(self) -> "LLMSettings":
184-
if self.provider in ("ollama", "bedrock", "codex"):
185-
return self # ollama: local; bedrock: IAM; codex: `codex login` (CLI)
186+
if self.provider in ("ollama", "bedrock", "codex", "claude-code"):
187+
return self # ollama: local; bedrock: IAM; codex/claude-code: CLI auth
186188
provider_to_key = {
187189
"anthropic": self.anthropic_api_key,
188190
"openai": self.openai_api_key,
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Anthropic Claude Code CLI adapter (``claude -p``, non-interactive / print mode).
2+
3+
Env vars
4+
--------
5+
CLAUDE_CODE_BIN Optional explicit path to the ``claude`` binary.
6+
Blank or non-runnable paths are ignored; PATH + fallbacks apply.
7+
CLAUDE_CODE_MODEL Optional model override (e.g. ``claude-opus-4-7``).
8+
Unset or empty → omit ``--model``; CLI default applies.
9+
10+
Auth
11+
----
12+
Claude Code authenticates via ``ANTHROPIC_API_KEY`` (env var) or OAuth credentials
13+
stored in ``~/.claude/.credentials.json`` after ``claude login``.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
import re
20+
import subprocess
21+
import sys
22+
from pathlib import Path
23+
24+
from app.integrations.llm_cli.base import CLIInvocation, CLIProbe
25+
from app.integrations.llm_cli.binary_resolver import (
26+
candidate_binary_names as _candidate_binary_names,
27+
)
28+
from app.integrations.llm_cli.binary_resolver import (
29+
default_cli_fallback_paths as _default_cli_fallback_paths,
30+
)
31+
from app.integrations.llm_cli.binary_resolver import (
32+
resolve_cli_binary,
33+
)
34+
35+
_CLAUDE_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+)")
36+
# Claude Code's `--version` does config/cache init that can spike past Codex's 3s
37+
# budget on cold starts or when another claude process holds shared state.
38+
_PROBE_TIMEOUT_SEC = 8.0
39+
40+
41+
def _parse_semver(text: str) -> str | None:
42+
m = _CLAUDE_VERSION_RE.search(text)
43+
return m.group(1) if m else None
44+
45+
46+
def _classify_claude_code_auth() -> tuple[bool | None, str]:
47+
"""Return (logged_in, detail) without spawning a subprocess.
48+
49+
Resolution order:
50+
1. ANTHROPIC_API_KEY in env → True (definitive; build() forwards it).
51+
2. ~/.claude/.credentials.json present and non-empty → True (OAuth login).
52+
3. macOS without either → None: Claude Code stores OAuth in Keychain on
53+
darwin, so file absence is not proof of no-auth — let invocation reveal.
54+
4. Otherwise → False (Linux/Windows: file is the canonical credential store).
55+
"""
56+
if os.environ.get("ANTHROPIC_API_KEY", "").strip():
57+
return True, "Authenticated via ANTHROPIC_API_KEY."
58+
creds_path = Path.home() / ".claude" / ".credentials.json"
59+
try:
60+
if creds_path.exists() and creds_path.stat().st_size > 2:
61+
return True, "Authenticated via ~/.claude/.credentials.json (OAuth login)."
62+
except OSError:
63+
return None, "Could not read ~/.claude/.credentials.json; auth state unclear."
64+
if sys.platform == "darwin":
65+
return None, (
66+
"ANTHROPIC_API_KEY not set and ~/.claude/.credentials.json absent; "
67+
"macOS may use Keychain — auth state unclear, invocation will verify."
68+
)
69+
return (
70+
False,
71+
"Not authenticated. Run: claude login or set ANTHROPIC_API_KEY.",
72+
)
73+
74+
75+
def _fallback_claude_code_paths() -> list[str]:
76+
return _default_cli_fallback_paths("claude")
77+
78+
79+
class ClaudeCodeAdapter:
80+
"""Non-interactive Claude Code CLI (``claude -p``, print mode, no TTY)."""
81+
82+
name = "claude-code"
83+
binary_env_key = "CLAUDE_CODE_BIN"
84+
install_hint = "npm i -g @anthropic-ai/claude-code"
85+
auth_hint = "Run: claude login or set ANTHROPIC_API_KEY"
86+
min_version: str | None = None
87+
default_exec_timeout_sec = 120.0
88+
89+
def _resolve_binary(self) -> str | None:
90+
return resolve_cli_binary(
91+
explicit_env_key="CLAUDE_CODE_BIN",
92+
binary_names=_candidate_binary_names("claude"),
93+
fallback_paths=_fallback_claude_code_paths,
94+
)
95+
96+
def _probe_binary(self, binary_path: str) -> CLIProbe:
97+
try:
98+
ver_proc = subprocess.run(
99+
[binary_path, "--version"],
100+
capture_output=True,
101+
text=True,
102+
timeout=_PROBE_TIMEOUT_SEC,
103+
check=False,
104+
)
105+
except (OSError, subprocess.TimeoutExpired) as exc:
106+
return CLIProbe(
107+
installed=False,
108+
version=None,
109+
logged_in=None,
110+
bin_path=None,
111+
detail=f"Could not run `{binary_path} --version`: {exc}",
112+
)
113+
114+
if ver_proc.returncode != 0:
115+
err = (ver_proc.stderr or ver_proc.stdout or "").strip()
116+
return CLIProbe(
117+
installed=False,
118+
version=None,
119+
logged_in=None,
120+
bin_path=None,
121+
detail=f"`{binary_path} --version` failed: {err or 'unknown error'}",
122+
)
123+
124+
version = _parse_semver(ver_proc.stdout + ver_proc.stderr)
125+
logged_in, auth_detail = _classify_claude_code_auth()
126+
return CLIProbe(
127+
installed=True,
128+
version=version,
129+
logged_in=logged_in,
130+
bin_path=binary_path,
131+
detail=auth_detail,
132+
)
133+
134+
def detect(self) -> CLIProbe:
135+
binary = self._resolve_binary()
136+
if not binary:
137+
return CLIProbe(
138+
installed=False,
139+
version=None,
140+
logged_in=None,
141+
bin_path=None,
142+
detail=(
143+
"Claude Code CLI not found on PATH or known install locations. "
144+
f"Install with: {self.install_hint} or set CLAUDE_CODE_BIN."
145+
),
146+
)
147+
return self._probe_binary(binary)
148+
149+
def build(self, *, prompt: str, model: str | None, workspace: str) -> CLIInvocation:
150+
binary = self._resolve_binary()
151+
if not binary:
152+
raise RuntimeError(
153+
f"Claude Code CLI not found. {self.install_hint}"
154+
" or set CLAUDE_CODE_BIN to the full binary path."
155+
)
156+
157+
cwd = workspace or os.getcwd()
158+
159+
argv: list[str] = [
160+
binary,
161+
"-p",
162+
"--output-format",
163+
"text",
164+
]
165+
166+
resolved_model = (model or "").strip()
167+
if resolved_model:
168+
argv.extend(["--model", resolved_model])
169+
170+
# Forward Anthropic auth vars explicitly rather than relying on a blanket
171+
# prefix allowlist, so they don't leak into other CLI adapters (e.g. Codex).
172+
env: dict[str, str] = {"NO_COLOR": "1"}
173+
for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"):
174+
val = os.environ.get(key, "").strip()
175+
if val:
176+
env[key] = val
177+
178+
return CLIInvocation(
179+
argv=tuple(argv),
180+
stdin=prompt,
181+
cwd=cwd,
182+
env=env,
183+
timeout_sec=self.default_exec_timeout_sec,
184+
)
185+
186+
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
187+
del stderr, returncode
188+
return (stdout or "").strip()
189+
190+
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
191+
err = (stderr or "").strip()
192+
out = (stdout or "").strip()
193+
bits = [f"claude -p exited with code {returncode}"]
194+
if err:
195+
bits.append(err[:2000])
196+
elif out:
197+
bits.append(out[:2000])
198+
return ". ".join(bits)

app/integrations/llm_cli/registry.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,17 @@ def _codex_factory() -> LLMCLIAdapter:
2323
return CodexAdapter()
2424

2525

26+
def _claude_code_factory() -> LLMCLIAdapter:
27+
from app.integrations.llm_cli.claude_code import ClaudeCodeAdapter
28+
29+
return ClaudeCodeAdapter()
30+
31+
2632
CLI_PROVIDER_REGISTRY: dict[str, CLIProviderRegistration] = {
2733
"codex": CLIProviderRegistration(adapter_factory=_codex_factory, model_env_key="CODEX_MODEL"),
34+
"claude-code": CLIProviderRegistration(
35+
adapter_factory=_claude_code_factory, model_env_key="CLAUDE_CODE_MODEL"
36+
),
2837
}
2938

3039

app/integrations/llm_cli/runner.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
_SAFE_SUBPROCESS_ENV_KEYS = frozenset(
2525
{
2626
"HOME",
27+
# macOS Keychain item lookup (where `claude login` stores OAuth on darwin)
28+
# requires USER. LOGNAME is the POSIX/Linux equivalent kept for parity.
29+
"USER",
30+
"LOGNAME",
2731
"USERPROFILE",
2832
"APPDATA",
2933
"LOCALAPPDATA",
@@ -56,7 +60,7 @@
5660
"XDG_STATE_HOME",
5761
}
5862
)
59-
_SAFE_SUBPROCESS_ENV_PREFIXES = ("LC_", "CODEX_")
63+
_SAFE_SUBPROCESS_ENV_PREFIXES = ("LC_", "CODEX_", "CLAUDE_")
6064

6165

6266
def _strip_ansi(text: str) -> str:

0 commit comments

Comments
 (0)