The old code passed `--files <abspath>` to `claude -p`, but the CLI has no such flag — it errored out with "unknown option '--files'" on the first /run call carrying a file. The flag had been there since the file-attachment path was added and was apparently never exercised end to end against current claude versions. New behavior: each file_path is hardlinked (cross-fs falls back to copy) into the per-run cwd under a stable `attachment-N.<ext>` name, and the prompt is appended with a `Attached files in the current directory:` manifest pointing at the relative paths. claude -p's Read tool then loads each file on demand — including image content for vision models. acpx /sessions path unaffected — it has its own _format_prompt_with_files.
178 lines
6.7 KiB
Python
178 lines
6.7 KiB
Python
"""Wrap `claude -p ... --output-format json` and parse its result."""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass
|
|
class RunResult:
|
|
ok: bool
|
|
result: object # parsed JSON or text
|
|
raw_stdout: str
|
|
raw_stderr: str
|
|
duration_ms: int
|
|
stop_reason: str | None
|
|
error: str | None = None
|
|
|
|
|
|
class Runner:
|
|
def __init__(self, *, claude_bin: str, default_model: str, default_timeout: int, runs_dir: str):
|
|
self.claude_bin = claude_bin
|
|
self.default_model = default_model
|
|
self.default_timeout = default_timeout
|
|
self.runs_dir = runs_dir
|
|
Path(runs_dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
def run(
|
|
self,
|
|
*,
|
|
prompt: str,
|
|
model: str | None = None,
|
|
system: str | None = None,
|
|
system_mode: str | None = "append",
|
|
effort: str | None = None,
|
|
files: list[str] | None = None,
|
|
timeout_secs: int | None = None,
|
|
) -> RunResult:
|
|
# Linux ARG_MAX caps a CLI argument around 128KB-2MB depending on
|
|
# kernel + env. Long prompts (recipe corpora, big curate jobs) blow
|
|
# past it. For prompts > 64KB we pipe via stdin instead.
|
|
ARG_MAX_THRESHOLD = 64 * 1024 # bytes; leaves headroom for env+other args
|
|
use_stdin = len(prompt.encode("utf-8")) > ARG_MAX_THRESHOLD
|
|
|
|
cmd = [self.claude_bin, "-p"]
|
|
if not use_stdin:
|
|
cmd.append(prompt)
|
|
cmd += [
|
|
"--output-format", "json",
|
|
"--model", model or self.default_model,
|
|
]
|
|
if system:
|
|
# "append" = additive on top of claude's defaults; right
|
|
# for tool-using assistants. "replace" = REPLACES the
|
|
# default base prompt entirely; right for personas
|
|
# (fiction authors, chat bots, in-world characters) where
|
|
# claude's defaults would bleed through as friction.
|
|
if system_mode == "replace":
|
|
cmd += ["--system-prompt", system]
|
|
else:
|
|
cmd += ["--append-system-prompt", system]
|
|
if effort:
|
|
cmd += ["--effort", effort]
|
|
|
|
timeout = timeout_secs or self.default_timeout
|
|
|
|
# Stage a per-run dir so claude has a clean working directory
|
|
run_id = f"{int(time.time()*1000)}-{os.getpid()}"
|
|
cwd = Path(self.runs_dir) / run_id
|
|
cwd.mkdir(parents=True, exist_ok=True)
|
|
|
|
# File attachments are surfaced to claude by hardlinking (or
|
|
# copying, cross-fs) them into the per-run cwd under stable
|
|
# names `attachment-N.<ext>`, then appending a short manifest to
|
|
# the prompt that references them by relative path. claude -p's
|
|
# built-in Read tool then loads each on demand — including image
|
|
# content for vision models.
|
|
#
|
|
# Earlier impl tried `--files` / `--file` flags on the CLI but
|
|
# both are claude.ai-hosted file_id forms, not local-path
|
|
# accepting; pure-CWD staging is the only sane local path.
|
|
attachment_lines: list[str] = []
|
|
if files:
|
|
for idx, src in enumerate(files, start=1):
|
|
src_p = Path(src)
|
|
ext = src_p.suffix.lstrip(".") or "bin"
|
|
staged_name = f"attachment-{idx}.{ext}"
|
|
staged_path = cwd / staged_name
|
|
try:
|
|
os.link(src, staged_path)
|
|
except OSError:
|
|
# Cross-filesystem hardlinks fail with EXDEV; fall back to copy.
|
|
shutil.copy2(src, staged_path)
|
|
attachment_lines.append(f"- ./{staged_name}")
|
|
if attachment_lines:
|
|
manifest = (
|
|
"\n\nAttached files in the current directory (use the Read tool to view):\n"
|
|
+ "\n".join(attachment_lines)
|
|
)
|
|
if use_stdin:
|
|
# Mutate the input that goes to stdin.
|
|
prompt = prompt + manifest
|
|
else:
|
|
# Mutate the inline-positional arg we already pushed.
|
|
cmd[2] = cmd[2] + manifest
|
|
|
|
started = time.monotonic()
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
cwd=str(cwd),
|
|
input=prompt if use_stdin else None,
|
|
)
|
|
except subprocess.TimeoutExpired as e:
|
|
duration_ms = int((time.monotonic() - started) * 1000)
|
|
return RunResult(
|
|
ok=False,
|
|
result=None,
|
|
raw_stdout=(e.stdout or b"").decode("utf-8", "replace") if isinstance(e.stdout, bytes) else (e.stdout or ""),
|
|
raw_stderr=(e.stderr or b"").decode("utf-8", "replace") if isinstance(e.stderr, bytes) else (e.stderr or ""),
|
|
duration_ms=duration_ms,
|
|
stop_reason="timeout",
|
|
error=f"timeout after {timeout}s",
|
|
)
|
|
finally:
|
|
shutil.rmtree(cwd, ignore_errors=True)
|
|
|
|
duration_ms = int((time.monotonic() - started) * 1000)
|
|
|
|
if proc.returncode != 0:
|
|
return RunResult(
|
|
ok=False,
|
|
result=None,
|
|
raw_stdout=proc.stdout,
|
|
raw_stderr=proc.stderr,
|
|
duration_ms=duration_ms,
|
|
stop_reason="error",
|
|
error=f"claude exit {proc.returncode}",
|
|
)
|
|
|
|
# claude --output-format json wraps in {"type":"result","result":"...","stop_reason":...}
|
|
parsed: object
|
|
stop_reason: str | None = None
|
|
try:
|
|
outer = json.loads(proc.stdout)
|
|
if isinstance(outer, dict) and "result" in outer:
|
|
stop_reason = outer.get("stop_reason") or outer.get("subtype")
|
|
inner = (outer["result"] or "").strip()
|
|
if inner.startswith("```"):
|
|
# strip ```lang fenced block
|
|
parts = inner.split("\n", 1)
|
|
if len(parts) == 2:
|
|
inner = parts[1].rsplit("```", 1)[0].strip()
|
|
# Try to JSON-parse the inner; if it's not valid JSON, return as text
|
|
try:
|
|
parsed = json.loads(inner)
|
|
except json.JSONDecodeError:
|
|
parsed = inner
|
|
else:
|
|
parsed = outer
|
|
except json.JSONDecodeError:
|
|
# Outer wasn't even JSON. Return raw text.
|
|
parsed = proc.stdout.strip()
|
|
|
|
return RunResult(
|
|
ok=True,
|
|
result=parsed,
|
|
raw_stdout=proc.stdout,
|
|
raw_stderr=proc.stderr,
|
|
duration_ms=duration_ms,
|
|
stop_reason=stop_reason,
|
|
error=None,
|
|
)
|