""" Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess. """ import asyncio import subprocess from typing import Optional from llm_connect.adapter import LLMAdapter from llm_connect.models import RunConfig, LLMResponse from llm_connect.config import LLMConfig from llm_connect._token_estimator import estimate_tokens from llm_connect.exceptions import ( LLMSubprocessError, LLMTimeoutError, ) class ClaudeCodeAdapter(LLMAdapter): """LLM adapter that shells out to the ``claude`` CLI with ``--print``. The compiled prompt is piped via **stdin** to avoid shell argument length limits (compiled prompts can exceed 30 KB). """ def __init__( self, cli_path: str = "claude", model: Optional[str] = None, config: Optional[LLMConfig] = None, ): self._config = config or LLMConfig(provider="claude-code") self._cli_path = cli_path or self._config.claude_cli_path self._model = model # ── LLMAdapter interface ──────────────────────────────────────── def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: self._preflight_budget(config) cmd = [self._cli_path, "--print"] if self._model: cmd.extend(["--model", self._model]) timeout = config.timeout_seconds or self._config.timeout_seconds try: result = subprocess.run( cmd, input=prompt, capture_output=True, text=True, timeout=timeout, ) except subprocess.TimeoutExpired as exc: raise LLMTimeoutError( f"claude CLI timed out after {timeout}s", cause=exc, ) from exc if result.returncode != 0: raise LLMSubprocessError( f"claude CLI exited with code {result.returncode}", return_code=result.returncode, stderr=result.stderr, ) content = result.stdout prompt_tokens = estimate_tokens(prompt) completion_tokens = estimate_tokens(content) response = LLMResponse( content=content, model=self._model or "claude-code-cli", usage={ "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens, }, finish_reason="stop", metadata={ "provider": "claude-code", "cli_path": self._cli_path, }, ) self._consume_budget(config, response) return response async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: """Native async implementation using asyncio.create_subprocess_exec.""" self._preflight_budget(config) cmd = [self._cli_path, "--print"] if self._model: cmd.extend(["--model", self._model]) timeout = config.timeout_seconds or self._config.timeout_seconds try: proc = await asyncio.create_subprocess_exec( *cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout_bytes, stderr_bytes = await asyncio.wait_for( proc.communicate(input=prompt.encode()), timeout=timeout, ) except asyncio.TimeoutError as exc: raise LLMTimeoutError( f"claude CLI timed out after {timeout}s", cause=exc, ) from exc if proc.returncode != 0: raise LLMSubprocessError( f"claude CLI exited with code {proc.returncode}", return_code=proc.returncode, stderr=stderr_bytes.decode(), ) content = stdout_bytes.decode() prompt_tokens = estimate_tokens(prompt) completion_tokens = estimate_tokens(content) response = LLMResponse( content=content, model=self._model or "claude-code-cli", usage={ "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens, }, finish_reason="stop", metadata={ "provider": "claude-code", "cli_path": self._cli_path, "async": True, }, ) self._consume_budget(config, response) return response def validate_config(self, config: RunConfig) -> bool: try: result = subprocess.run( [self._cli_path, "--version"], capture_output=True, text=True, timeout=10, ) return result.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError, OSError): return False