"""Claude CLI backend.""" import json import os import shutil import subprocess from tai_pkg.clirun import Cli from tai_pkg.errors import BackendError from tai_pkg.muse import Muse from tai_pkg.opencode import Opencode from tai_pkg.text import Text __all__ = ["Claude"] class Claude: """Claude.""" @staticmethod def claude_binary(): return os.environ.get("CLAUDE_BIN") or shutil.which("claude") @staticmethod def claude_parse_output(stdout): try: data = json.loads(stdout or "") except ValueError: return Opencode.opencode_parse(stdout) if not isinstance(data, dict): return Opencode.opencode_parse(stdout) if data.get("is_error"): raise BackendError("claude: %s" % str(data.get("result") or "unknown error")[:300]) structured = data.get("structured_output") if isinstance(structured, dict) and ("content" in structured or "tool_calls" in structured): return structured result = data.get("result") if isinstance(result, str) and result.strip(): return Opencode.opencode_parse(result) return Opencode.opencode_parse(stdout) @staticmethod def claude_run(prompt, timeout): binary = Claude.claude_binary() if not binary: raise BackendError("claude CLI not found (CLAUDE_BIN or PATH)") schema = json.dumps(Muse.muse_output_schema()) base = [binary, "-p"] if len(prompt.encode("utf-8", "replace")) > 120000: variants = [ (base + ["--output-format", "json", "--json-schema", schema], prompt), (base + ["--output-format", "json"], prompt), ] else: variants = [ (base + [prompt, "--output-format", "json", "--json-schema", schema], None), (base + [prompt, "--output-format", "json"], None), (base + [prompt], None), ] last_error = "" for argv, prompt_input in variants: done = Cli._cli_run(argv, prompt_input, timeout, "claude") if done.returncode != 0 and Cli._cli_unknown_flag(done.stderr) and (argv, prompt_input) != variants[-1]: last_error = (done.stderr or "").strip()[:200] continue if done.returncode != 0 and not (done.stdout or "").strip(): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if Cli._cli_auth_failure(blob): raise BackendError("claude not authenticated (run claude login): %s" % blob[:200]) raise BackendError("claude exit %d: %s" % (done.returncode, blob[:300] or last_error or "no output")) if not (done.stdout or "").strip(): blob = (done.stderr or "").strip() raise BackendError("claude returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "" raise BackendError("claude failed: %s" % (last_error or "no usable output")) @staticmethod def claude_health(): binary = Claude.claude_binary() if not binary: return "missing: claude CLI not found" try: done = subprocess.run([binary, "auth", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: return "fail: %s" % Text.short_error(exc)[:60] try: info = json.loads(done.stdout) except ValueError: info = None if isinstance(info, dict) and "loggedIn" in info: if info["loggedIn"]: return "ok, subscription (%s)" % (info.get("authMethod") or "logged in") return "not logged in" # `claude auth status` didn't return the expected shape (older/newer # CLI) -- fall back to the honest version-only signal instead of # claiming a subscription this check couldn't actually verify. return Cli._cli_version_only_health(binary, "claude")