You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

137 lines
4.5 KiB
Python

"""Claude Code CLI subprocess wrapper."""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
from typing import Optional
from loguru import logger
from gimme_job.models.runtime import ClaudeRunResult
DEFAULT_LEARN_TOOLS = ["Read", "Edit", "Write", "Bash", "Grep", "Glob"]
DEFAULT_REPAIR_TOOLS = ["Read", "Edit", "Write", "Bash", "Grep", "Glob"]
DEFAULT_TIMEOUT_SECONDS = 600
class ClaudeCodeClient:
def __init__(self, cwd: Optional[Path] = None, timeout: int = DEFAULT_TIMEOUT_SECONDS):
from gimme_job.utils.paths import project_root
self.cwd = str(cwd or project_root())
self.timeout = timeout
def run_prompt(
self,
prompt: str,
cwd: Optional[Path] = None,
allowed_tools: Optional[list[str]] = None,
max_turns: int = 30,
) -> ClaudeRunResult:
"""Run a prompt via `claude -p`. Returns parsed ClaudeRunResult."""
tools = allowed_tools or DEFAULT_LEARN_TOOLS
cmd = [
"claude",
"-p", prompt,
"--output-format", "json",
"--max-turns", str(max_turns),
"--allowedTools", ",".join(tools),
]
work_dir = str(cwd) if cwd else self.cwd
start = time.time()
logger.info(f"Calling Claude Code CLI (cwd={work_dir})")
logger.debug(f"Command: {' '.join(cmd[:4])}...")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=work_dir,
timeout=self.timeout,
)
duration_ms = int((time.time() - start) * 1000)
if result.returncode != 0:
logger.error(f"Claude Code returned non-zero: {result.returncode}")
logger.debug(f"stderr: {result.stderr[:500]}")
return ClaudeRunResult(
success=False,
output=result.stdout,
error=result.stderr[:500],
duration_ms=duration_ms,
)
# Parse JSON output
parsed = self._parse_output(result.stdout)
return ClaudeRunResult(
success=True,
session_id=parsed.get("session_id"),
output=parsed.get("result", result.stdout),
cost_usd=parsed.get("cost_usd"),
duration_ms=duration_ms,
)
except subprocess.TimeoutExpired:
logger.error(f"Claude Code timed out after {self.timeout}s")
return ClaudeRunResult(
success=False,
error=f"Timed out after {self.timeout}s",
)
except FileNotFoundError:
logger.error("Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code")
return ClaudeRunResult(success=False, error="claude CLI not found")
def resume(
self,
session_id: str,
prompt: str,
cwd: Optional[Path] = None,
) -> ClaudeRunResult:
"""Resume a previous session via `--resume`."""
cmd = [
"claude",
"-p", prompt,
"--resume", session_id,
"--output-format", "json",
]
work_dir = str(cwd) if cwd else self.cwd
start = time.time()
try:
result = subprocess.run(
cmd, capture_output=True, text=True, cwd=work_dir, timeout=self.timeout
)
duration_ms = int((time.time() - start) * 1000)
parsed = self._parse_output(result.stdout)
return ClaudeRunResult(
success=result.returncode == 0,
session_id=parsed.get("session_id"),
output=parsed.get("result", result.stdout),
duration_ms=duration_ms,
error=result.stderr[:500] if result.returncode != 0 else None,
)
except Exception as e:
return ClaudeRunResult(success=False, error=str(e))
def _parse_output(self, raw: str) -> dict:
"""Parse Claude Code JSON output, handling streaming line format."""
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
# Try last non-empty line (streaming format)
lines = [l.strip() for l in raw.strip().splitlines() if l.strip()]
for line in reversed(lines):
try:
return json.loads(line)
except json.JSONDecodeError:
continue
return {"result": raw}