Split monolithic tai.py into tai_pkg package, keep tai-orig.py as original
tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user