callai-cli 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- callai/__init__.py +37 -0
- callai/__main__.py +8 -0
- callai/adapters/__init__.py +82 -0
- callai/adapters/base.py +190 -0
- callai/adapters/claude_code.py +194 -0
- callai/adapters/codex.py +155 -0
- callai/adapters/grok.py +207 -0
- callai/adapters/hermes.py +112 -0
- callai/adapters/opencode.py +186 -0
- callai/adapters/pi.py +179 -0
- callai/api.py +134 -0
- callai/cli.py +674 -0
- callai/config.py +273 -0
- callai/errors.py +65 -0
- callai/fanout.py +166 -0
- callai/py.typed +0 -0
- callai/request.py +70 -0
- callai/result.py +95 -0
- callai/runner.py +233 -0
- callai_cli-0.9.0.dist-info/METADATA +218 -0
- callai_cli-0.9.0.dist-info/RECORD +25 -0
- callai_cli-0.9.0.dist-info/WHEEL +5 -0
- callai_cli-0.9.0.dist-info/entry_points.txt +2 -0
- callai_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
- callai_cli-0.9.0.dist-info/top_level.txt +1 -0
callai/cli.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
"""Command-line interface: ``callai -n HARNESS_NAME -p PROMPT [options] [-- HARNESS_ARGS...]`` (DESIGN.md §3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import select
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
15
|
+
from typing import IO, Any
|
|
16
|
+
|
|
17
|
+
from . import runner
|
|
18
|
+
from .adapters import HARNESS_NAMES, Adapter, get_adapter
|
|
19
|
+
from .api import run_harness
|
|
20
|
+
from .config import Config, load_config, resolve_model
|
|
21
|
+
from .errors import CallaiError, NoHarnessInstalled, UsageError
|
|
22
|
+
from .fanout import ALL, PARALLEL, SINGLE, Job, Outcome, run_jobs, split_harness_names
|
|
23
|
+
from .request import PERMISSIONS, Request
|
|
24
|
+
from .result import Completed
|
|
25
|
+
|
|
26
|
+
OUTPUT_CHOICES = ("text", "json", "stream-json", "raw")
|
|
27
|
+
_WRITE_LOCK = threading.Lock()
|
|
28
|
+
# Seconds to wait for the first stdin data when -p TEXT is given (claude -p waits 3s too).
|
|
29
|
+
DEFAULT_STDIN_WAIT = 3.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
"""Build the ``callai`` argument parser.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The configured parser.
|
|
37
|
+
"""
|
|
38
|
+
from . import __version__
|
|
39
|
+
|
|
40
|
+
p = argparse.ArgumentParser(
|
|
41
|
+
prog="callai",
|
|
42
|
+
description="Run a prompt non-interactively through a coding-agent harness.",
|
|
43
|
+
epilog="Arguments after a bare `--` are passed to the harness unchanged.",
|
|
44
|
+
)
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"-n",
|
|
47
|
+
"--name",
|
|
48
|
+
metavar="HARNESS_NAME",
|
|
49
|
+
help=f"harness to run: {', '.join(HARNESS_NAMES)} (default: $CALLAI_HARNESS_NAME)",
|
|
50
|
+
)
|
|
51
|
+
p.add_argument(
|
|
52
|
+
"-p",
|
|
53
|
+
"-c",
|
|
54
|
+
"--prompt",
|
|
55
|
+
"--command",
|
|
56
|
+
dest="prompt",
|
|
57
|
+
metavar="PROMPT",
|
|
58
|
+
help="the prompt; '-' reads it from stdin. Piped stdin is appended after a blank line.",
|
|
59
|
+
)
|
|
60
|
+
p.add_argument("-m", "--model", help="model, passed to the harness verbatim")
|
|
61
|
+
p.add_argument("-o", "--output-format", choices=OUTPUT_CHOICES, default="text", help="default: text")
|
|
62
|
+
p.add_argument("-C", "--cwd", help="working directory for the harness")
|
|
63
|
+
p.add_argument("--timeout", type=float, metavar="SECS", help="kill the harness after SECS (exit 124)")
|
|
64
|
+
p.add_argument("--permission", choices=PERMISSIONS, default="default", help="default: the harness's own")
|
|
65
|
+
p.add_argument("--system-prompt", help="system prompt, where the harness supports one")
|
|
66
|
+
p.add_argument("--resume", metavar="SESSION_ID", help="continue this session (a session_id callai reported)")
|
|
67
|
+
p.add_argument(
|
|
68
|
+
"--continue",
|
|
69
|
+
dest="continue_session",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="continue the harness's most recent session (no -c: that's --command)",
|
|
72
|
+
)
|
|
73
|
+
p.add_argument("--dry-run", action="store_true", help="print the harness command line and exit")
|
|
74
|
+
p.add_argument("-v", "--verbose", action="store_true", help="stream the harness's stderr live")
|
|
75
|
+
p.add_argument(
|
|
76
|
+
"--list-harnesses",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="list every harness: its binary, where it is on PATH, and its version (-o json for JSON)",
|
|
79
|
+
)
|
|
80
|
+
p.add_argument("--version", action="version", version=f"callai {__version__}")
|
|
81
|
+
return p
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def split_passthrough(argv: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
85
|
+
"""Split callai's own arguments from harness passthrough arguments at the first bare ``--``.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
argv: Arguments excluding the program name.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
``(own, passthrough)``.
|
|
92
|
+
"""
|
|
93
|
+
argv = list(argv)
|
|
94
|
+
if "--" in argv:
|
|
95
|
+
i = argv.index("--")
|
|
96
|
+
return argv[:i], argv[i + 1 :]
|
|
97
|
+
return argv, []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def stdin_wait(environ: Mapping[str, str]) -> float:
|
|
101
|
+
"""Seconds to wait for stdin data when ``-p TEXT`` is given, from ``CALLAI_STDIN_WAIT``.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
environ: Environment to read ``CALLAI_STDIN_WAIT`` from.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The wait; :data:`DEFAULT_STDIN_WAIT` if unset or empty.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
UsageError: If the value isn't a finite number of seconds >= 0.
|
|
111
|
+
"""
|
|
112
|
+
raw = environ.get("CALLAI_STDIN_WAIT") or ""
|
|
113
|
+
if not raw:
|
|
114
|
+
return DEFAULT_STDIN_WAIT
|
|
115
|
+
try:
|
|
116
|
+
wait = float(raw)
|
|
117
|
+
except ValueError:
|
|
118
|
+
wait = -1.0
|
|
119
|
+
if not math.isfinite(wait) or wait < 0:
|
|
120
|
+
raise UsageError(f"CALLAI_STDIN_WAIT must be a number of seconds >= 0, got {raw!r}")
|
|
121
|
+
return wait
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _stdin_ready(stream: IO[str], wait: float) -> bool:
|
|
125
|
+
"""Whether ``stream`` has data or EOF within ``wait`` seconds.
|
|
126
|
+
|
|
127
|
+
Streams without a real file descriptor, and platforms where ``select`` can't watch pipes
|
|
128
|
+
(Windows), count as ready, so they are read as before.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
stream: A non-TTY stdin.
|
|
132
|
+
wait: Seconds to wait.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
``False`` only if nothing arrived in time.
|
|
136
|
+
"""
|
|
137
|
+
if os.name == "nt":
|
|
138
|
+
return True
|
|
139
|
+
try:
|
|
140
|
+
fd = stream.fileno()
|
|
141
|
+
ready, _, _ = select.select([fd], [], [], wait)
|
|
142
|
+
except (AttributeError, OSError, ValueError): # io.UnsupportedOperation is both OSError and ValueError
|
|
143
|
+
return True
|
|
144
|
+
return bool(ready)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_stdin(stream: IO[str] | None, wait: float | None = None) -> str:
|
|
148
|
+
"""Read piped stdin; a terminal or unreadable stdin counts as no input.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
stream: Usually ``sys.stdin``.
|
|
152
|
+
wait: If given, seconds to wait for the first data (or EOF) before giving up with a
|
|
153
|
+
warning, so a pipe that stays open and silent doesn't block. ``None`` waits for EOF.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The text with trailing newlines stripped, or ``""``.
|
|
157
|
+
"""
|
|
158
|
+
if stream is None or stream.isatty():
|
|
159
|
+
return ""
|
|
160
|
+
if wait is not None and not _stdin_ready(stream, wait):
|
|
161
|
+
sys.stderr.write(
|
|
162
|
+
f"callai: warning: no stdin data received in {wait:g}s, proceeding without it. "
|
|
163
|
+
"Redirect stdin (< /dev/null) to skip the wait, or set CALLAI_STDIN_WAIT to wait longer.\n"
|
|
164
|
+
)
|
|
165
|
+
return ""
|
|
166
|
+
try:
|
|
167
|
+
return stream.read().rstrip("\r\n")
|
|
168
|
+
except (OSError, ValueError):
|
|
169
|
+
return ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def resolve_prompt(prompt: str | None, stdin_text: str) -> str:
|
|
173
|
+
"""Combine ``-p`` and piped stdin like ``claude -p`` (DESIGN.md §3).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
prompt: The ``-p`` value, ``"-"`` for stdin, or ``None``.
|
|
177
|
+
stdin_text: Piped stdin, ``""`` if none.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The final prompt.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
UsageError: If there is no prompt at all.
|
|
184
|
+
"""
|
|
185
|
+
if prompt == "-":
|
|
186
|
+
prompt = stdin_text
|
|
187
|
+
elif prompt and stdin_text:
|
|
188
|
+
prompt = f"{prompt}\n\n{stdin_text}"
|
|
189
|
+
elif not prompt:
|
|
190
|
+
prompt = stdin_text
|
|
191
|
+
if not prompt:
|
|
192
|
+
raise UsageError("a prompt is required: use -p PROMPT or pipe it on stdin")
|
|
193
|
+
return prompt
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def resolve_harness_name(ns: argparse.Namespace, environ: Mapping[str, str], config: Config | None = None) -> str:
|
|
197
|
+
"""The harness name from ``-n``, then ``CALLAI_HARNESS_NAME``, then the config's ``default_harness``.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
ns: Parsed callai arguments.
|
|
201
|
+
environ: Environment for the ``CALLAI_HARNESS_NAME`` default.
|
|
202
|
+
config: User config, if loaded.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The harness name (not yet checked against the registry).
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
UsageError: If none of them is set.
|
|
209
|
+
"""
|
|
210
|
+
config = config or Config()
|
|
211
|
+
harness_name = ns.name or environ.get("CALLAI_HARNESS_NAME") or config.default_harness
|
|
212
|
+
if harness_name is None:
|
|
213
|
+
if environ.get("CALLAI_TOOL"):
|
|
214
|
+
raise UsageError("CALLAI_TOOL was renamed to CALLAI_HARNESS_NAME; set that instead")
|
|
215
|
+
raise UsageError(
|
|
216
|
+
"no harness given: use -n/--name, set CALLAI_HARNESS_NAME, or set default_harness in the config file"
|
|
217
|
+
)
|
|
218
|
+
return harness_name
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def env_timeout(environ: Mapping[str, str]) -> float | None:
|
|
222
|
+
"""``CALLAI_TIMEOUT`` in seconds, or ``None`` if unset or empty.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
environ: Environment to read it from.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The timeout.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
UsageError: If it isn't a finite number of seconds > 0.
|
|
232
|
+
"""
|
|
233
|
+
raw = environ.get("CALLAI_TIMEOUT") or ""
|
|
234
|
+
if not raw:
|
|
235
|
+
return None
|
|
236
|
+
try:
|
|
237
|
+
value = float(raw)
|
|
238
|
+
except ValueError:
|
|
239
|
+
value = 0.0
|
|
240
|
+
if not math.isfinite(value) or value <= 0:
|
|
241
|
+
raise UsageError(f"CALLAI_TIMEOUT must be a positive number of seconds, got {raw!r}")
|
|
242
|
+
return value
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def harness_binary(adapter: Adapter, config: Config) -> str:
|
|
246
|
+
"""The executable to run: ``[harnesses.<name>] binary`` from the config, else the adapter's own.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
adapter: The harness's adapter.
|
|
250
|
+
config: User config.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The binary name or path (``~`` expanded).
|
|
254
|
+
"""
|
|
255
|
+
configured = config.harnesses.get(adapter.name)
|
|
256
|
+
if configured and configured.binary:
|
|
257
|
+
return os.path.expanduser(configured.binary)
|
|
258
|
+
return adapter.binary
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _request(
|
|
262
|
+
ns: argparse.Namespace,
|
|
263
|
+
extra: Sequence[str],
|
|
264
|
+
harness_name: str,
|
|
265
|
+
prompt: str,
|
|
266
|
+
environ: Mapping[str, str],
|
|
267
|
+
config: Config,
|
|
268
|
+
) -> Request:
|
|
269
|
+
"""Build a :class:`Request`, filling unset options from ``CALLAI_*`` and the config (DESIGN.md §6).
|
|
270
|
+
|
|
271
|
+
Precedence for each option: flag > environment variable > ``[harnesses.<name>]`` > top level.
|
|
272
|
+
Model aliases are resolved last, so they work from any of those.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
ns: Parsed callai arguments.
|
|
276
|
+
extra: Passthrough arguments for the harness.
|
|
277
|
+
harness_name: Harness name.
|
|
278
|
+
prompt: The final prompt (or a placeholder, for :func:`preflight`).
|
|
279
|
+
environ: Environment for ``CALLAI_MODEL`` / ``CALLAI_TIMEOUT``.
|
|
280
|
+
config: User config.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
The request.
|
|
284
|
+
"""
|
|
285
|
+
per_harness = config.harnesses.get(harness_name)
|
|
286
|
+
model = ns.model or environ.get("CALLAI_MODEL") or (per_harness and per_harness.model) or config.model
|
|
287
|
+
timeout = ns.timeout if ns.timeout is not None else env_timeout(environ)
|
|
288
|
+
return Request(
|
|
289
|
+
harness_name=harness_name,
|
|
290
|
+
prompt=prompt,
|
|
291
|
+
model=resolve_model(config, harness_name, model or None),
|
|
292
|
+
output_format=ns.output_format,
|
|
293
|
+
cwd=ns.cwd,
|
|
294
|
+
timeout=timeout if timeout is not None else config.timeout,
|
|
295
|
+
permission=ns.permission,
|
|
296
|
+
system_prompt=ns.system_prompt,
|
|
297
|
+
extra_args=(*(per_harness.extra_args if per_harness else ()), *extra),
|
|
298
|
+
resume=ns.resume,
|
|
299
|
+
continue_session=ns.continue_session,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def make_request(
|
|
304
|
+
ns: argparse.Namespace,
|
|
305
|
+
extra: Sequence[str],
|
|
306
|
+
stdin_text: str,
|
|
307
|
+
environ: Mapping[str, str],
|
|
308
|
+
config: Config | None = None,
|
|
309
|
+
) -> Request:
|
|
310
|
+
"""Build a validated :class:`Request` from parsed arguments (missing or invalid harness or prompt: UsageError).
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
ns: Parsed callai arguments.
|
|
314
|
+
extra: Passthrough arguments for the harness.
|
|
315
|
+
stdin_text: Piped stdin, ``""`` if none.
|
|
316
|
+
environ: Environment for defaults such as ``CALLAI_HARNESS_NAME``.
|
|
317
|
+
config: User config; ``None`` means none.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
The request.
|
|
321
|
+
"""
|
|
322
|
+
config = config or Config()
|
|
323
|
+
harness_name = resolve_harness_name(ns, environ, config)
|
|
324
|
+
get_adapter(harness_name) # unknown / unimplemented harness -> UsageError before reading anything else
|
|
325
|
+
return _request(ns, extra, harness_name, resolve_prompt(ns.prompt, stdin_text), environ, config)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def preflight(
|
|
329
|
+
ns: argparse.Namespace, extra: Sequence[str], environ: Mapping[str, str], config: Config | None = None
|
|
330
|
+
) -> Adapter:
|
|
331
|
+
"""Raise every usage error that doesn't depend on the prompt, before stdin is read.
|
|
332
|
+
|
|
333
|
+
Reading stdin can block (the prompt may come only from a slow pipe), so a refused
|
|
334
|
+
permission level or option shouldn't have to wait for it. Errors that depend on the
|
|
335
|
+
prompt text (e.g. grok's slash commands) still come after. Errors are raised as UsageError.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
ns: Parsed callai arguments.
|
|
339
|
+
extra: Passthrough arguments for the harness.
|
|
340
|
+
environ: Environment for defaults such as ``CALLAI_HARNESS_NAME``.
|
|
341
|
+
config: User config; ``None`` means none.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
The adapter for the harness.
|
|
345
|
+
"""
|
|
346
|
+
config = config or Config()
|
|
347
|
+
return _check_harness(ns, extra, resolve_harness_name(ns, environ, config), environ, config)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _check_harness(
|
|
351
|
+
ns: argparse.Namespace, extra: Sequence[str], harness_name: str, environ: Mapping[str, str], config: Config
|
|
352
|
+
) -> Adapter:
|
|
353
|
+
"""Raise the usage errors one harness has for these options, with a placeholder prompt.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
ns: Parsed callai arguments.
|
|
357
|
+
extra: Passthrough arguments for the harness.
|
|
358
|
+
harness_name: The harness.
|
|
359
|
+
environ: Environment for defaults.
|
|
360
|
+
config: User config.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
The harness's adapter.
|
|
364
|
+
"""
|
|
365
|
+
adapter = get_adapter(harness_name)
|
|
366
|
+
adapter.build_argv(_request(ns, extra, harness_name, "callai preflight", environ, config))
|
|
367
|
+
return adapter
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _render(completed: Completed, adapter: Adapter, req: Request, verbose: bool) -> int:
|
|
371
|
+
"""Print a finished run in the requested output format and pick the exit code.
|
|
372
|
+
|
|
373
|
+
Harness stderr is shown only on failure, unless ``verbose`` already streamed it.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
completed: Raw process outcome.
|
|
377
|
+
adapter: Adapter that parses it.
|
|
378
|
+
req: The request (its ``output_format`` decides the rendering).
|
|
379
|
+
verbose: Whether stderr was already streamed live.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The harness's exit code, or 1 if it exited 0 but reported an error.
|
|
383
|
+
"""
|
|
384
|
+
if req.output_format == "raw":
|
|
385
|
+
sys.stdout.write(completed.stdout)
|
|
386
|
+
if not verbose:
|
|
387
|
+
sys.stderr.write(completed.stderr)
|
|
388
|
+
return completed.exit_code
|
|
389
|
+
result = adapter.parse(completed, req)
|
|
390
|
+
if result.is_error and not verbose and completed.stderr:
|
|
391
|
+
sys.stderr.write(completed.stderr)
|
|
392
|
+
if req.output_format == "json":
|
|
393
|
+
sys.stdout.write(json.dumps(result.to_dict(), ensure_ascii=False) + "\n")
|
|
394
|
+
elif req.output_format == "stream-json":
|
|
395
|
+
_write_event({"type": "result", **result.to_dict()})
|
|
396
|
+
elif result.is_error:
|
|
397
|
+
# Harness stderr was already shown (buffered above, or live with -v); don't repeat what it said.
|
|
398
|
+
if result.text and result.text not in completed.stderr: # e.g. pi: the error text *is* its stderr
|
|
399
|
+
sys.stderr.write(result.text + "\n")
|
|
400
|
+
else:
|
|
401
|
+
sys.stdout.write(result.text + "\n")
|
|
402
|
+
return completed.exit_code or (1 if result.is_error else 0)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _write_event(event: dict[str, Any]) -> None:
|
|
406
|
+
"""Write one ``-o stream-json`` event as a JSON line and flush, so readers see it at once.
|
|
407
|
+
|
|
408
|
+
Thread-safe: a parallel fan-out writes from several threads.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
event: The event.
|
|
412
|
+
"""
|
|
413
|
+
line = json.dumps(event, ensure_ascii=False) + "\n"
|
|
414
|
+
with _WRITE_LOCK:
|
|
415
|
+
sys.stdout.write(line)
|
|
416
|
+
sys.stdout.flush()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def dry_run_line(argv: Sequence[str], env: Mapping[str, str], output_format: str) -> str:
|
|
420
|
+
"""What ``--dry-run`` prints: the harness command line and the env vars callai would set for it.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
argv: The harness command line.
|
|
424
|
+
env: Variables the adapter sets for the harness.
|
|
425
|
+
output_format: ``"json"`` or ``"stream-json"`` for ``{"argv": [...], "env": {...}}`` (``env`` only
|
|
426
|
+
when non-empty), anything else for a shell line with ``NAME=value`` assignments before the command.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
The line, without a trailing newline.
|
|
430
|
+
"""
|
|
431
|
+
if output_format in ("json", "stream-json"):
|
|
432
|
+
return json.dumps({"argv": list(argv), **({"env": dict(env)} if env else {})}, ensure_ascii=False)
|
|
433
|
+
return " ".join([*(f"{k}={shlex.quote(v)}" for k, v in env.items()), shlex.join(argv)])
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _execute(ns: argparse.Namespace, extra: Sequence[str]) -> int:
|
|
437
|
+
"""Build the request, then dry-run it or run the harness and render the result.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
ns: Parsed callai arguments.
|
|
441
|
+
extra: Passthrough arguments for the harness.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
The process exit code.
|
|
445
|
+
"""
|
|
446
|
+
config = load_config(os.environ)
|
|
447
|
+
names, mode = split_harness_names(resolve_harness_name(ns, os.environ, config))
|
|
448
|
+
if mode != SINGLE:
|
|
449
|
+
return _execute_many(ns, extra, names, mode, config)
|
|
450
|
+
adapter = preflight(ns, extra, os.environ, config)
|
|
451
|
+
# With -p TEXT, stdin is optional extra input: don't let a pipe that never closes block the run.
|
|
452
|
+
# Without it (or with -p -), stdin is the prompt, so wait for EOF however long it takes.
|
|
453
|
+
wait = stdin_wait(os.environ) if ns.prompt and ns.prompt != "-" else None
|
|
454
|
+
req = make_request(ns, extra, read_stdin(sys.stdin, wait), os.environ, config)
|
|
455
|
+
argv = [harness_binary(adapter, config), *adapter.build_argv(req)[1:]]
|
|
456
|
+
if ns.dry_run:
|
|
457
|
+
sys.stdout.write(dry_run_line(argv, adapter.build_env(req), req.output_format) + "\n")
|
|
458
|
+
return 0
|
|
459
|
+
if req.permission == "bypass":
|
|
460
|
+
_warn_bypass(adapter)
|
|
461
|
+
# A harness that can't start writes no stream events: a missing binary is exit 127 with no start.
|
|
462
|
+
on_event = _write_event if req.output_format == "stream-json" else None
|
|
463
|
+
completed = run_harness(adapter, req, argv, verbose=ns.verbose, on_event=on_event)
|
|
464
|
+
return _render(completed, adapter, req, ns.verbose)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _warn_bypass(adapter: Adapter) -> None:
|
|
468
|
+
"""Warn on stderr that a harness is about to run with every permission.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
adapter: The harness's adapter.
|
|
472
|
+
"""
|
|
473
|
+
sys.stderr.write(
|
|
474
|
+
f"callai: warning: --permission bypass: {adapter.name} will run every tool call without asking, "
|
|
475
|
+
"including shell commands and writes outside the working directory.\n"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _execute_many(ns: argparse.Namespace, extra: Sequence[str], names: list[str], mode: str, config: Config) -> int:
|
|
480
|
+
"""Run one prompt through several harnesses (``-n a/b``, ``-n a,b``, ``-n all``) and render the results.
|
|
481
|
+
|
|
482
|
+
Everything that can be checked is checked before any harness starts. With an explicit list, a
|
|
483
|
+
harness that refuses the options or isn't installed is an error; ``-n all`` takes the installed
|
|
484
|
+
harnesses and skips (with a note) those that refuse the options.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
ns: Parsed callai arguments.
|
|
488
|
+
extra: Passthrough arguments (refused: they'd be for one harness).
|
|
489
|
+
names: The harnesses, in output order.
|
|
490
|
+
mode: ``PARALLEL``, ``SEQUENTIAL`` or ``ALL``.
|
|
491
|
+
config: User config.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
0 if every run succeeded, else the first failed run's exit code (in the order given).
|
|
495
|
+
|
|
496
|
+
Raises:
|
|
497
|
+
UsageError: For ``-o raw``, arguments after ``--``, or a refused option (explicit list).
|
|
498
|
+
NoHarnessInstalled: For ``-n all`` when no supported harness is installed.
|
|
499
|
+
"""
|
|
500
|
+
if ns.output_format == "raw":
|
|
501
|
+
raise UsageError("-o raw needs a single harness (several raw outputs can't be told apart)")
|
|
502
|
+
if extra:
|
|
503
|
+
raise UsageError(
|
|
504
|
+
"arguments after -- go to one harness; for several, set [harnesses.<name>] extra_args in the config file"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def refused(name: str, exc: UsageError) -> None:
|
|
508
|
+
"""Skip a harness under ``-n all``; anywhere else its refusal is the error.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
name: The harness.
|
|
512
|
+
exc: Its usage error.
|
|
513
|
+
|
|
514
|
+
Raises:
|
|
515
|
+
UsageError: Unless ``-n all``: the refusal, prefixed with the harness name.
|
|
516
|
+
"""
|
|
517
|
+
if mode != ALL:
|
|
518
|
+
raise UsageError(f"{name}: {exc}") from None
|
|
519
|
+
sys.stderr.write(f"callai: skipping {name}: {exc}\n")
|
|
520
|
+
|
|
521
|
+
adapters = []
|
|
522
|
+
for name in names:
|
|
523
|
+
adapter = get_adapter(name)
|
|
524
|
+
if mode == ALL and shutil.which(harness_binary(adapter, config)) is None:
|
|
525
|
+
continue
|
|
526
|
+
try:
|
|
527
|
+
_check_harness(ns, extra, name, os.environ, config)
|
|
528
|
+
except UsageError as exc:
|
|
529
|
+
refused(name, exc)
|
|
530
|
+
continue
|
|
531
|
+
adapters.append(adapter)
|
|
532
|
+
if mode == ALL and not adapters and not any(shutil.which(harness_binary(get_adapter(n), config)) for n in names):
|
|
533
|
+
raise NoHarnessInstalled("-n all: none of the supported harnesses is installed (see callai --list-harnesses)")
|
|
534
|
+
wait = stdin_wait(os.environ) if ns.prompt and ns.prompt != "-" else None
|
|
535
|
+
prompt = resolve_prompt(ns.prompt, read_stdin(sys.stdin, wait))
|
|
536
|
+
jobs = []
|
|
537
|
+
for adapter in adapters:
|
|
538
|
+
try:
|
|
539
|
+
req = _request(ns, extra, adapter.name, prompt, os.environ, config)
|
|
540
|
+
argv = (harness_binary(adapter, config), *adapter.build_argv(req)[1:])
|
|
541
|
+
except UsageError as exc: # prompt-dependent refusals, e.g. grok's slash commands
|
|
542
|
+
refused(adapter.name, exc)
|
|
543
|
+
continue
|
|
544
|
+
jobs.append(Job(adapter, req, argv))
|
|
545
|
+
if not jobs:
|
|
546
|
+
raise UsageError("-n all: every installed harness refused these options")
|
|
547
|
+
if ns.dry_run:
|
|
548
|
+
if ns.output_format in ("json", "stream-json"):
|
|
549
|
+
runs = [
|
|
550
|
+
{
|
|
551
|
+
"harness_name": j.adapter.name,
|
|
552
|
+
"argv": list(j.argv),
|
|
553
|
+
**({"env": env} if (env := j.adapter.build_env(j.req)) else {}),
|
|
554
|
+
}
|
|
555
|
+
for j in jobs
|
|
556
|
+
]
|
|
557
|
+
sys.stdout.write(json.dumps({"runs": runs}, ensure_ascii=False) + "\n")
|
|
558
|
+
else:
|
|
559
|
+
for j in jobs:
|
|
560
|
+
sys.stdout.write(dry_run_line(j.argv, j.adapter.build_env(j.req), "text") + "\n")
|
|
561
|
+
return 0
|
|
562
|
+
for j in jobs: # a missing binary in an explicit list stops the call before anything runs
|
|
563
|
+
runner.resolve_binary(j.argv[0], harness_name=j.adapter.name, install_url=j.adapter.install_url)
|
|
564
|
+
for j in jobs:
|
|
565
|
+
if j.req.permission == "bypass":
|
|
566
|
+
_warn_bypass(j.adapter)
|
|
567
|
+
|
|
568
|
+
def handler(job: Job) -> Callable[[dict[str, Any]], None] | None:
|
|
569
|
+
"""Event callback for one harness: its events, tagged with its name.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
job: The harness's job.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
The callback, or ``None`` unless ``-o stream-json``.
|
|
576
|
+
"""
|
|
577
|
+
if ns.output_format != "stream-json":
|
|
578
|
+
return None
|
|
579
|
+
return lambda event: _write_event({"type": event["type"], "harness_name": job.adapter.name, **event})
|
|
580
|
+
|
|
581
|
+
outcomes = run_jobs(jobs, concurrent=mode in (PARALLEL, ALL), verbose=ns.verbose, event_handler=handler)
|
|
582
|
+
return _render_many(outcomes, ns.output_format, ns.verbose)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _render_many(outcomes: Sequence[Outcome], output_format: str, verbose: bool) -> int:
|
|
586
|
+
"""Print a fan-out's results in the order given, and pick the exit code.
|
|
587
|
+
|
|
588
|
+
Text gives each answer under a ``== name ==`` header (``== name (failed, exit N) ==`` for a
|
|
589
|
+
failure, with its error under it); json gives ``{"results": [...]}``; stream-json was written
|
|
590
|
+
as it happened. A failed harness's own stderr follows on stderr, unless ``-v`` showed it live.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
outcomes: One per harness.
|
|
594
|
+
output_format: ``text``, ``json`` or ``stream-json``.
|
|
595
|
+
verbose: Whether stderr was already shown live.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
0 if every run succeeded, else the first failed run's exit code (1 if it exited 0).
|
|
599
|
+
"""
|
|
600
|
+
if output_format == "json":
|
|
601
|
+
sys.stdout.write(json.dumps({"results": [o.result.to_dict() for o in outcomes]}, ensure_ascii=False) + "\n")
|
|
602
|
+
for o in outcomes:
|
|
603
|
+
r = o.result
|
|
604
|
+
if output_format == "text":
|
|
605
|
+
status = f" (failed, exit {r.exit_code})" if r.is_error else ""
|
|
606
|
+
sys.stdout.write(f"== {r.harness_name}{status} ==\n{r.text}\n")
|
|
607
|
+
if r.is_error and not verbose and o.completed is not None and o.completed.stderr:
|
|
608
|
+
err = o.completed.stderr
|
|
609
|
+
sys.stderr.write(f"--- {r.harness_name} stderr ---\n{err}{'' if err.endswith(chr(10)) else chr(10)}")
|
|
610
|
+
failed = next((o.result for o in outcomes if o.result.is_error), None)
|
|
611
|
+
return 0 if failed is None else (failed.exit_code or 1)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def list_harnesses(output_format: str, config: Config) -> int:
|
|
615
|
+
"""Print every harness callai supports, whether its binary is installed, and its version.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
output_format: ``"json"`` for one JSON object, anything else for one line per harness.
|
|
619
|
+
config: User config (``[harnesses.<name>] binary`` overrides the binary looked up).
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
Exit code 0.
|
|
623
|
+
"""
|
|
624
|
+
rows = []
|
|
625
|
+
for name in HARNESS_NAMES:
|
|
626
|
+
adapter = get_adapter(name)
|
|
627
|
+
binary = harness_binary(adapter, config)
|
|
628
|
+
path = shutil.which(binary)
|
|
629
|
+
version = runner.harness_version(path) if path else None
|
|
630
|
+
rows.append(
|
|
631
|
+
{
|
|
632
|
+
"harness_name": name,
|
|
633
|
+
"binary": binary,
|
|
634
|
+
"path": path,
|
|
635
|
+
"version": version,
|
|
636
|
+
"install_url": adapter.install_url,
|
|
637
|
+
}
|
|
638
|
+
)
|
|
639
|
+
if output_format == "json":
|
|
640
|
+
sys.stdout.write(json.dumps({"harnesses": rows}, ensure_ascii=False) + "\n")
|
|
641
|
+
return 0
|
|
642
|
+
for row in rows:
|
|
643
|
+
if row["path"] is None:
|
|
644
|
+
status = f"not installed (see {row['install_url']})"
|
|
645
|
+
else:
|
|
646
|
+
status = f"{row['version'] or 'version unknown'} {row['path']}"
|
|
647
|
+
sys.stdout.write(f"{row['harness_name']:<12} {row['binary']:<10} {status}\n")
|
|
648
|
+
return 0
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
652
|
+
"""Run the CLI.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
argv: Arguments excluding the program name; defaults to ``sys.argv[1:]``.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
The process exit code (DESIGN.md §8).
|
|
659
|
+
"""
|
|
660
|
+
own, extra = split_passthrough(sys.argv[1:] if argv is None else argv)
|
|
661
|
+
parser = build_parser()
|
|
662
|
+
try:
|
|
663
|
+
ns = parser.parse_args(own)
|
|
664
|
+
except SystemExit as exc: # --help / --version / argparse usage errors
|
|
665
|
+
return int(exc.code or 0)
|
|
666
|
+
try:
|
|
667
|
+
if ns.list_harnesses:
|
|
668
|
+
return list_harnesses(ns.output_format, load_config(os.environ))
|
|
669
|
+
return _execute(ns, extra)
|
|
670
|
+
except CallaiError as exc:
|
|
671
|
+
if isinstance(exc, UsageError):
|
|
672
|
+
parser.print_usage(sys.stderr)
|
|
673
|
+
sys.stderr.write(f"callai: error: {exc}\n")
|
|
674
|
+
return exc.exit_code
|