diff --git a/src/applypilot/config.py b/src/applypilot/config.py index 8c397807..31c5772d 100644 --- a/src/applypilot/config.py +++ b/src/applypilot/config.py @@ -206,7 +206,10 @@ def get_tier() -> int: """ load_env() + use_claude_code_llm = os.environ.get("LLM_PROVIDER", "").strip().lower() == "claude_code" has_llm = any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")) + if use_claude_code_llm and shutil.which("claude") is not None: + has_llm = True if not has_llm: return 1 @@ -238,8 +241,13 @@ def check_tier(required: int, feature: str) -> None: _console = Console(stderr=True) missing: list[str] = [] - if required >= 2 and not any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")): - missing.append("LLM API key — run [bold]applypilot init[/bold] or set GEMINI_API_KEY") + use_claude_code_llm = os.environ.get("LLM_PROVIDER", "").strip().lower() == "claude_code" + has_llm = any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")) + if use_claude_code_llm and shutil.which("claude") is not None: + has_llm = True + + if required >= 2 and not has_llm: + missing.append("LLM backend — run [bold]applypilot init[/bold], set GEMINI_API_KEY, or set LLM_PROVIDER=claude_code") if required >= 3: if not shutil.which("claude"): missing.append("Claude Code CLI — install from [bold]https://claude.ai/code[/bold]") diff --git a/src/applypilot/llm.py b/src/applypilot/llm.py index 1fb7be64..fbb9286f 100644 --- a/src/applypilot/llm.py +++ b/src/applypilot/llm.py @@ -2,6 +2,7 @@ Unified LLM client for ApplyPilot. Auto-detects provider from environment: + LLM_PROVIDER=claude_code -> Claude Code CLI (no API key required) GEMINI_API_KEY -> Google Gemini (default: gemini-2.0-flash) OPENAI_API_KEY -> OpenAI (default: gpt-4o-mini) LLM_URL -> Local llama.cpp / Ollama compatible endpoint @@ -11,6 +12,8 @@ import logging import os +import shutil +import subprocess import time import httpx @@ -31,6 +34,19 @@ def _detect_provider() -> tuple[str, str, str]: openai_key = os.environ.get("OPENAI_API_KEY", "") local_url = os.environ.get("LLM_URL", "") model_override = os.environ.get("LLM_MODEL", "") + provider_override = os.environ.get("LLM_PROVIDER", "").strip().lower() + + if provider_override == "claude_code": + if shutil.which("claude") is None: + raise RuntimeError( + "LLM_PROVIDER=claude_code is set, but Claude Code CLI is not on PATH. " + "Install from https://claude.ai/code" + ) + return ( + "claude-code-cli", + model_override or "haiku", + "", + ) if gemini_key and not local_url: return ( @@ -55,7 +71,7 @@ def _detect_provider() -> tuple[str, str, str]: raise RuntimeError( "No LLM provider configured. " - "Set GEMINI_API_KEY, OPENAI_API_KEY, or LLM_URL in your environment." + "Set LLM_PROVIDER=claude_code, GEMINI_API_KEY, OPENAI_API_KEY, or LLM_URL in your environment." ) @@ -69,6 +85,7 @@ def _detect_provider() -> tuple[str, str, str]: # Base wait on first 429/503 (doubles each retry, caps at 60s). # Gemini free tier is 15 RPM = 4s minimum between requests; 10s gives headroom. _RATE_LIMIT_BASE_WAIT = 10 +_CLAUDE_CLI_TIMEOUT = 300 _GEMINI_COMPAT_BASE = "https://generativelanguage.googleapis.com/v1beta/openai" @@ -92,6 +109,60 @@ def __init__(self, base_url: str, model: str, api_key: str) -> None: # True once we've confirmed the native Gemini API works for this model self._use_native_gemini: bool = False self._is_gemini: bool = base_url.startswith(_GEMINI_COMPAT_BASE) + self._is_claude_code: bool = base_url == "claude-code-cli" + + # -- Claude Code CLI ---------------------------------------------------- + + @staticmethod + def _messages_to_prompt(messages: list[dict]) -> str: + """Convert OpenAI-style messages into one deterministic text prompt.""" + parts: list[str] = [] + for msg in messages: + role = msg.get("role", "user").upper() + content = msg.get("content", "") + parts.append(f"{role}:\n{content}\n") + parts.append("ASSISTANT:\n") + return "\n".join(parts) + + def _chat_claude_code( + self, + messages: list[dict], + ) -> str: + """Call Claude Code CLI as the LLM backend for scoring/tailoring stages.""" + prompt = self._messages_to_prompt(messages) + cmd = [ + "claude", + "--print", + "--model", + self.model, + "--output-format", + "text", + "--input-format", + "text", + "--permission-mode", + "bypassPermissions", + "--no-session-persistence", + "--tools", + "", + "-", + ] + + proc = subprocess.run( + cmd, + input=prompt, + text=True, + capture_output=True, + timeout=_CLAUDE_CLI_TIMEOUT, + check=False, + ) + + if proc.returncode != 0: + stderr = (proc.stderr or "").strip() + stdout = (proc.stdout or "").strip() + detail = stderr or stdout or f"exit code {proc.returncode}" + raise RuntimeError(f"Claude Code CLI failed: {detail[:500]}") + + return (proc.stdout or "").strip() # -- Native Gemini API -------------------------------------------------- @@ -201,6 +272,9 @@ def chat( for attempt in range(_MAX_RETRIES): try: + if self._is_claude_code: + return self._chat_claude_code(messages) + # Route to native Gemini if we've already confirmed it's needed if self._use_native_gemini: return self._chat_native_gemini(messages, temperature, max_tokens) diff --git a/src/applypilot/wizard/init.py b/src/applypilot/wizard/init.py index 0f893c3a..df39df46 100644 --- a/src/applypilot/wizard/init.py +++ b/src/applypilot/wizard/init.py @@ -245,10 +245,10 @@ def _setup_ai_features() -> None: console.print("[dim]Discovery-only mode. You can configure AI later with [bold]applypilot init[/bold].[/dim]") return - console.print("Supported providers: [bold]Gemini[/bold] (recommended, free tier), OpenAI, local (Ollama/llama.cpp)") + console.print("Supported providers: [bold]Gemini[/bold] (recommended, free tier), OpenAI, local (Ollama/llama.cpp), [bold]Claude Code CLI[/bold]") provider = Prompt.ask( "Provider", - choices=["gemini", "openai", "local"], + choices=["gemini", "openai", "local", "claude_code"], default="gemini", ) @@ -269,6 +269,10 @@ def _setup_ai_features() -> None: model = Prompt.ask("Model name", default="local-model") env_lines.append(f"LLM_URL={url}") env_lines.append(f"LLM_MODEL={model}") + elif provider == "claude_code": + model = Prompt.ask("Claude model alias", default="haiku") + env_lines.append("LLM_PROVIDER=claude_code") + env_lines.append(f"LLM_MODEL={model}") env_lines.append("") ENV_PATH.write_text("\n".join(env_lines), encoding="utf-8")