← refacto-feature-scout
"""Run headless Claude Code (`claude -p`) and hand back its answer."""
from __future__ import annotations
import json
import os
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Sequence
from .llm import claude_env
@dataclass
class ClaudeResult:
ok: bool
text: str = ""
cost_usd: float = 0.0
error: str = ""
raw: dict[str, Any] = field(default_factory=dict)
session_id: str = ""
Runner = Callable[..., ClaudeResult]
def claude_bin() -> str:
for cand in (os.environ.get("SCOUT_CLAUDE_BIN"), str(Path.home() / ".local" / "bin" / "claude"),
"/opt/homebrew/bin/claude", "/usr/local/bin/claude", "/usr/bin/claude",
str(Path.home() / ".claude" / "local" / "claude")):
if cand and Path(cand).exists():
return cand
return "claude"
def run_claude(prompt: str, *, cwd: Path | str, allowed_tools: Sequence[str], max_turns: int,
timeout_s: int, model: str | None = None, permission_mode: str | None = None,
log_path: Path | None = None, resume: str | None = None,
disallowed_tools: Sequence[str] = ()) -> ClaudeResult:
cmd = [claude_bin(), "-p", prompt, "--output-format", "json",
"--allowedTools", ",".join(allowed_tools), "--max-turns", str(max_turns)]
if disallowed_tools:
cmd += ["--disallowedTools", ",".join(disallowed_tools)]
if resume:
cmd += ["--resume", resume]
if model:
cmd += ["--model", model]
if permission_mode:
cmd += ["--permission-mode", permission_mode]
try:
proc = subprocess.run(cmd, cwd=str(cwd), env=claude_env(), capture_output=True, text=True,
timeout=timeout_s)
except subprocess.TimeoutExpired:
return ClaudeResult(ok=False, error=f"claude timed out after {timeout_s}s")
except FileNotFoundError:
return ClaudeResult(ok=False, error="the `claude` command was not found on this computer")
if log_path:
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(proc.stdout + "\n--- stderr ---\n" + proc.stderr)
if proc.returncode != 0 and not proc.stdout.strip():
return ClaudeResult(ok=False, error=f"claude exited {proc.returncode}: {proc.stderr[-2000:]}")
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError:
return ClaudeResult(ok=False, error=f"claude returned non-JSON output: {proc.stdout[-500:]}")
if isinstance(data, list): # stream-ish output: last result wins
data = next((d for d in reversed(data) if d.get("type") == "result"), data[-1] if data else {})
text = data.get("result") or ""
sid = str(data.get("session_id") or "")
if data.get("is_error"):
return ClaudeResult(ok=False, text=text, error=text or data.get("subtype", "error"), raw=data, session_id=sid)
return ClaudeResult(ok=True, text=text, cost_usd=float(data.get("total_cost_usd") or 0), raw=data, session_id=sid)
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.S)
def extract_json(text: str) -> Any:
"""Pull the first JSON value out of a model answer (fenced, bare, or wrapped in prose)."""
candidates = [m.group(1) for m in _FENCE.finditer(text)] + [text]
for cand in candidates:
cand = cand.strip()
try:
return json.loads(cand)
except json.JSONDecodeError:
pass
for opener, closer in (("[", "]"), ("{", "}")):
start = cand.find(opener)
end = cand.rfind(closer)
if start != -1 and end > start:
try:
return json.loads(cand[start:end + 1])
except json.JSONDecodeError:
continue
raise ValueError("no JSON found in model answer")