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/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