Advertisement

← refacto-feature-scout

scout/claude_runner.py

3.9 KB Download file
"""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")