moa-cli 0.1.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.
moa_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MOA CLI package."""
2
+
3
+ __version__ = "0.1.0"
moa_cli/cli.py ADDED
@@ -0,0 +1,543 @@
1
+ """moa - ask one question to multiple local AI coding CLIs in parallel.
2
+
3
+ Everything lives in this one module on purpose: the tool is small, and a single
4
+ file is easier to read end to end than five files that each do one small thing.
5
+ The sections below (providers / runner / synthesis / render / cli) are the seams
6
+ to split on if it ever genuinely outgrows one file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import os
14
+ import random
15
+ import shutil
16
+ import signal
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ from collections.abc import AsyncIterator, Callable
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Annotated, Literal
24
+
25
+ import typer
26
+
27
+ # --------------------------------------------------------------------------- #
28
+ # Providers: each agent CLI we know how to drive.
29
+ # --------------------------------------------------------------------------- #
30
+
31
+ # A command builder turns (prompt, model, output_file) into an argv list.
32
+ # output_file is a path the CLI may be told to write its final answer to; it is
33
+ # None for providers that answer cleanly on stdout. Only codex uses it.
34
+ CommandBuilder = Callable[[str, str, str | None], list[str]]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Provider:
39
+ name: str
40
+ executable: str
41
+ default_model: str
42
+ build: CommandBuilder
43
+ # Env keys to drop before spawning. claude refuses to run nested inside
44
+ # Claude Code unless CLAUDECODE is cleared, so moa can call it from an agent.
45
+ unset_env: tuple[str, ...] = ()
46
+ # codex's stdout is session chrome; its real answer goes to an output file.
47
+ uses_output_file: bool = False
48
+
49
+ def env(self) -> dict[str, str]:
50
+ env = dict(os.environ)
51
+ env.setdefault("NO_COLOR", "1")
52
+ env.setdefault("TERM", "dumb")
53
+ for key in self.unset_env:
54
+ env.pop(key, None)
55
+ return env
56
+
57
+
58
+ def _claude(prompt: str, model: str, _out: str | None) -> list[str]:
59
+ return ["claude", "--model", model, "-p", prompt]
60
+
61
+
62
+ def _codex(prompt: str, model: str, out: str | None) -> list[str]:
63
+ cmd = ["codex", "exec", "-m", model, "--skip-git-repo-check", "--color", "never"]
64
+ if out:
65
+ cmd += ["-o", out]
66
+ cmd.append(prompt)
67
+ return cmd
68
+
69
+
70
+ def _agy(prompt: str, model: str, _out: str | None) -> list[str]:
71
+ # agy also hosts Claude/GPT-OSS models, so we pin a Gemini model explicitly
72
+ # to keep the panel diverse. Without --model it defaults to Gemini Flash.
73
+ return ["agy", "--model", model, "-p", prompt]
74
+
75
+
76
+ def _opencode(prompt: str, model: str, _out: str | None) -> list[str]:
77
+ # opencode has no universal default model (it depends on which provider the
78
+ # user has authed), so we omit -m when no model is given and let opencode
79
+ # pick its own default. The prompt is a positional arg.
80
+ cmd = ["opencode", "run"]
81
+ if model:
82
+ cmd += ["-m", model]
83
+ cmd.append(prompt)
84
+ return cmd
85
+
86
+
87
+ PROVIDERS: dict[str, Provider] = {
88
+ "claude": Provider("claude", "claude", "opus", _claude, unset_env=("CLAUDECODE",)),
89
+ "codex": Provider("codex", "codex", "gpt-5.5", _codex, uses_output_file=True),
90
+ "agy": Provider("agy", "agy", "Gemini 3.1 Pro (High)", _agy),
91
+ "opencode": Provider("opencode", "opencode", "", _opencode),
92
+ }
93
+
94
+ # Auto-selection order, roughly by popularity. -n/--num walks this list and
95
+ # takes the first N that are actually installed.
96
+ PRIORITY: tuple[str, ...] = ("claude", "codex", "agy", "opencode")
97
+
98
+
99
+ def _installed(name: str) -> bool:
100
+ return shutil.which(PROVIDERS[name].executable) is not None
101
+
102
+
103
+ def available_provider_names() -> list[str]:
104
+ return [name for name in PRIORITY if _installed(name)]
105
+
106
+
107
+ def missing_provider_names() -> list[str]:
108
+ return [name for name in PRIORITY if not _installed(name)]
109
+
110
+
111
+ def select_for_run(
112
+ num: int, names: tuple[str, ...] | None, exclude: tuple[str, ...] = ()
113
+ ) -> tuple[list[Provider], list[str]]:
114
+ """Pick providers to run, returning (to_run, skipped_because_not_installed).
115
+
116
+ With an explicit `names` list we honour it in order, skipping any not
117
+ installed. Otherwise we take the first `num` installed providers from
118
+ PRIORITY. Excluded providers are dropped before either path takes effect, so
119
+ `-n` counts only non-excluded installs and `-p` pins drop excluded names too.
120
+ """
121
+ unknown = [name for name in (*(names or ()), *exclude) if name not in PROVIDERS]
122
+ if unknown:
123
+ raise ValueError(f"Unknown provider(s): {', '.join(unknown)}")
124
+ excluded = set(exclude)
125
+ if names:
126
+ chosen = [PROVIDERS[name] for name in names if name not in excluded and _installed(name)]
127
+ skipped = [name for name in names if name not in excluded and not _installed(name)]
128
+ return chosen, skipped
129
+ available = [name for name in available_provider_names() if name not in excluded]
130
+ return [PROVIDERS[name] for name in available[:num]], []
131
+
132
+
133
+ # --------------------------------------------------------------------------- #
134
+ # Runner: spawn each CLI as a parallel subprocess and collect its answer.
135
+ # --------------------------------------------------------------------------- #
136
+
137
+ Status = Literal["ok", "failed", "timeout", "missing"]
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class RunResult:
142
+ provider: str
143
+ model: str
144
+ status: Status
145
+ stdout: str
146
+ stderr: str
147
+ elapsed: float
148
+ returncode: int | None
149
+
150
+
151
+ def _decode(data: bytes | None) -> str:
152
+ return (data or b"").decode(errors="replace").strip()
153
+
154
+
155
+ async def _terminate(process: asyncio.subprocess.Process) -> None:
156
+ """Kill the whole process group (SIGTERM, then SIGKILL) on timeout."""
157
+ if process.returncode is not None:
158
+ return
159
+ try:
160
+ os.killpg(process.pid, signal.SIGTERM)
161
+ except ProcessLookupError:
162
+ return
163
+ except Exception:
164
+ process.terminate()
165
+ try:
166
+ await asyncio.wait_for(process.wait(), timeout=2)
167
+ return
168
+ except asyncio.TimeoutError:
169
+ pass
170
+ try:
171
+ os.killpg(process.pid, signal.SIGKILL)
172
+ except ProcessLookupError:
173
+ return
174
+ except Exception:
175
+ process.kill()
176
+ await process.wait()
177
+
178
+
179
+ async def run_provider(provider: Provider, prompt: str, timeout: float, model: str | None = None) -> RunResult:
180
+ model = model or provider.default_model
181
+ out_file: str | None = None
182
+ if provider.uses_output_file:
183
+ handle, out_file = tempfile.mkstemp(prefix="moa-", suffix=".txt")
184
+ os.close(handle)
185
+
186
+ start = time.monotonic()
187
+ try:
188
+ try:
189
+ process = await asyncio.create_subprocess_exec(
190
+ *provider.build(prompt, model, out_file),
191
+ # DEVNULL is essential: codex and agy block forever on an
192
+ # inherited TTY stdin, burning the entire timeout otherwise.
193
+ stdin=asyncio.subprocess.DEVNULL,
194
+ stdout=asyncio.subprocess.PIPE,
195
+ stderr=asyncio.subprocess.PIPE,
196
+ env=provider.env(),
197
+ start_new_session=True, # own process group, so _terminate can killpg
198
+ )
199
+ except FileNotFoundError:
200
+ return RunResult(provider.name, model, "missing", "", f"{provider.executable} is not installed.", time.monotonic() - start, None)
201
+
202
+ try:
203
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
204
+ except asyncio.TimeoutError:
205
+ await _terminate(process)
206
+ return RunResult(provider.name, model, "timeout", "", f"Timed out after {timeout:g}s.", time.monotonic() - start, None)
207
+
208
+ elapsed = time.monotonic() - start
209
+ error = _decode(stderr)
210
+ # For output-file providers the file is authoritative; stdout is noise,
211
+ # so an empty file means failure rather than reporting that noise.
212
+ if out_file:
213
+ answer = Path(out_file).read_text(encoding="utf-8", errors="replace").strip()
214
+ else:
215
+ answer = _decode(stdout)
216
+ status: Status = "ok" if process.returncode == 0 and answer else "failed"
217
+ return RunResult(provider.name, model, status, answer, error, elapsed, process.returncode)
218
+ finally:
219
+ if out_file:
220
+ try:
221
+ os.unlink(out_file)
222
+ except OSError:
223
+ pass
224
+
225
+
226
+ async def stream(
227
+ providers: list[Provider], prompt: str, timeout: float, models: dict[str, str] | None = None
228
+ ) -> AsyncIterator[RunResult]:
229
+ """Run every provider in parallel, yielding each result as it finishes."""
230
+ models = models or {}
231
+ tasks = [
232
+ asyncio.create_task(run_provider(p, prompt, timeout, models.get(p.name))) for p in providers
233
+ ]
234
+ for completed in asyncio.as_completed(tasks):
235
+ yield await completed
236
+
237
+
238
+ # --------------------------------------------------------------------------- #
239
+ # Synthesis: merge the collected answers into one unified answer.
240
+ # --------------------------------------------------------------------------- #
241
+
242
+ SYNTHESIZER_PROMPT = """You are the synthesizer in a mixture-of-agents system. You are given a \
243
+ user's question and several independent answers produced by different AI assistants. Produce a \
244
+ single, unified answer that is more accurate, complete, and useful than any individual response.
245
+
246
+ Guidelines:
247
+ - Identify where the answers agree, where they complement each other, and where they conflict.
248
+ - Resolve conflicts by the quality of reasoning and evidence; use agreement as a tiebreaker.
249
+ - Keep what is correct and valuable; drop what is wrong, redundant, or unsupported.
250
+ - Write a clear, well-structured, self-contained answer. Do not refer to "Response A", the other \
251
+ answers, or the fact that you are synthesizing. Just give the best possible answer.
252
+ - Do not invent information that none of the responses support."""
253
+
254
+
255
+ def choose_synthesizer(choice: str, candidates: list[str], rng: random.Random | None = None) -> str:
256
+ """Resolve --synthesizer against the providers that actually ran.
257
+
258
+ "auto"/"first" takes the highest-priority candidate, "random" picks one at
259
+ random, anything else must name a known provider.
260
+ """
261
+ if not candidates:
262
+ raise ValueError("No candidate providers available to synthesize.")
263
+ if choice in ("auto", "first"):
264
+ return candidates[0]
265
+ if choice == "random":
266
+ return (rng or random).choice(candidates)
267
+ if choice in PROVIDERS:
268
+ return choice
269
+ raise ValueError(f"Unknown synthesizer: {choice}")
270
+
271
+
272
+ def build_synthesis_prompt(
273
+ question: str,
274
+ results: list[RunResult],
275
+ blind: bool,
276
+ rng: random.Random | None = None,
277
+ ) -> tuple[str, dict[str, str]]:
278
+ """Build the synthesizer prompt and return (prompt, label_map).
279
+
280
+ In blind mode the answers are shuffled and shown as "Response A/B/C" with no
281
+ provider names, so the synthesizer can't favour a brand. The label_map
282
+ (A -> claude, ...) lets the caller reveal attribution afterwards.
283
+ """
284
+ answers = [r for r in results if r.status == "ok"]
285
+ sections: list[str] = []
286
+ label_map: dict[str, str] = {}
287
+
288
+ if blind:
289
+ shuffled = list(answers)
290
+ (rng or random).shuffle(shuffled)
291
+ for offset, result in enumerate(shuffled):
292
+ tag = chr(ord("A") + offset)
293
+ sections.append(f"### Response {tag}\n\n{result.stdout.strip()}")
294
+ label_map[tag] = result.provider
295
+ else:
296
+ for result in answers:
297
+ sections.append(f"### {result.provider}\n\n{result.stdout.strip()}")
298
+ label_map[result.provider] = result.provider
299
+
300
+ prompt = (
301
+ f"{SYNTHESIZER_PROMPT}\n\n"
302
+ f"## User question\n\n{question}\n\n"
303
+ f"## Responses to synthesize\n\n" + "\n\n".join(sections) + "\n\n## Your synthesized answer\n"
304
+ )
305
+ return prompt, label_map
306
+
307
+
308
+ # --------------------------------------------------------------------------- #
309
+ # Render: stdout carries content (Markdown or JSONL); stderr carries progress.
310
+ # --------------------------------------------------------------------------- #
311
+
312
+ _STATUS_LABELS = {"ok": "OK", "failed": "FAILED", "timeout": "TIMEOUT", "missing": "MISSING"}
313
+
314
+
315
+ def _status_label(status: str) -> str:
316
+ return _STATUS_LABELS.get(status, status.upper())
317
+
318
+
319
+ def _body(result: RunResult) -> list[str]:
320
+ if result.status == "ok":
321
+ return [result.stdout.strip(), ""]
322
+ detail = result.stderr or f"Process exited with return code {result.returncode}."
323
+ return ["```text", detail[-1200:], "```", ""]
324
+
325
+
326
+ def render_block(result: RunResult) -> str:
327
+ model = f" ({result.model})" if result.model else ""
328
+ heading = f"## {result.provider}{model} - {_status_label(result.status)} - {result.elapsed:.1f}s"
329
+ return "\n".join([heading, "", *_body(result)])
330
+
331
+
332
+ def render_synthesis_block(result: RunResult, synthesizer: str) -> str:
333
+ heading = f"## synthesis · via {synthesizer} - {_status_label(result.status)} - {result.elapsed:.1f}s"
334
+ return "\n".join([heading, "", *_body(result)])
335
+
336
+
337
+ def result_record(result: RunResult) -> dict:
338
+ return {
339
+ "type": "response",
340
+ "provider": result.provider,
341
+ "model": result.model,
342
+ "status": result.status,
343
+ "elapsed": round(result.elapsed, 3),
344
+ "returncode": result.returncode,
345
+ "text": result.stdout,
346
+ "stderr": result.stderr,
347
+ }
348
+
349
+
350
+ def synthesis_record(result: RunResult, synthesizer: str) -> dict:
351
+ return {
352
+ "type": "synthesis",
353
+ "synthesizer": synthesizer,
354
+ "status": result.status,
355
+ "elapsed": round(result.elapsed, 3),
356
+ "text": result.stdout,
357
+ "stderr": result.stderr,
358
+ }
359
+
360
+
361
+ # --------------------------------------------------------------------------- #
362
+ # CLI
363
+ # --------------------------------------------------------------------------- #
364
+
365
+ app = typer.Typer(
366
+ name="moa",
367
+ help="Ask one question to multiple local AI coding CLIs in parallel and collect their answers.",
368
+ no_args_is_help=True,
369
+ add_completion=False,
370
+ )
371
+
372
+
373
+ def parse_model_overrides(entries: list[str] | None) -> dict[str, str]:
374
+ """Parse repeated `-m provider=model` flags into a {provider: model} dict.
375
+
376
+ Each entry must contain `=` and name a known provider. The model string is
377
+ passed through verbatim (formats differ per tool); the underlying CLI
378
+ validates it. Bad format or unknown provider raises BadParameter.
379
+ """
380
+ models: dict[str, str] = {}
381
+ for entry in entries or []:
382
+ if "=" not in entry:
383
+ raise typer.BadParameter(f"--model expects PROVIDER=MODEL, got: {entry!r}")
384
+ provider, model = entry.split("=", 1)
385
+ provider = provider.strip()
386
+ if provider not in PROVIDERS:
387
+ raise typer.BadParameter(
388
+ f"Unknown provider in --model: {provider!r}. Known: {', '.join(PROVIDERS)}."
389
+ )
390
+ models[provider] = model
391
+ return models
392
+
393
+
394
+ def _read_prompt(prompt: str | None, file: Path | None) -> str:
395
+ if file is not None:
396
+ if str(file) == "-":
397
+ return sys.stdin.read().strip()
398
+ return file.read_text(encoding="utf-8").strip()
399
+ if prompt == "-":
400
+ return sys.stdin.read().strip()
401
+ if prompt:
402
+ return prompt.strip()
403
+ if not sys.stdin.isatty():
404
+ return sys.stdin.read().strip()
405
+ raise typer.BadParameter("Provide a prompt, --file, or pipe prompt text on stdin.")
406
+
407
+
408
+ def _note(message: str) -> None:
409
+ """Progress and selection notes go to stderr so stdout stays pure content."""
410
+ typer.echo(message, err=True)
411
+
412
+
413
+ def _emit(text: str) -> None:
414
+ sys.stdout.write(text.rstrip("\n") + "\n")
415
+ sys.stdout.flush()
416
+
417
+
418
+ async def _collect(
419
+ providers: list[Provider],
420
+ prompt: str,
421
+ timeout: float,
422
+ json_output: bool,
423
+ models: dict[str, str] | None = None,
424
+ ) -> list[RunResult]:
425
+ results: list[RunResult] = []
426
+ async for result in stream(providers, prompt, timeout, models):
427
+ results.append(result)
428
+ _emit(json.dumps(result_record(result)) if json_output else render_block(result))
429
+ return results
430
+
431
+
432
+ @app.command()
433
+ def ask(
434
+ prompt: Annotated[str | None, typer.Argument(help="Prompt to send to each agent. Use '-' for stdin.")] = None,
435
+ num: Annotated[int, typer.Option("--num", "-n", help="How many agents to ask, taken in priority order.")] = 3,
436
+ provider: Annotated[
437
+ list[str] | None,
438
+ typer.Option("--provider", "-p", help="Pin specific agent(s). Repeatable. Overrides --num."),
439
+ ] = None,
440
+ exclude: Annotated[
441
+ list[str] | None,
442
+ typer.Option("--exclude", "-x", help="Drop agent(s) from the run. Repeatable."),
443
+ ] = None,
444
+ model: Annotated[
445
+ list[str] | None,
446
+ typer.Option("--model", "-m", help="Override a tool's model: PROVIDER=MODEL. Repeatable."),
447
+ ] = None,
448
+ file: Annotated[Path | None, typer.Option("--file", "-f", help="Read the prompt from a file or '-' for stdin.")] = None,
449
+ timeout: Annotated[float, typer.Option("--timeout", "-t", help="Per-agent timeout in seconds.")] = 180,
450
+ synth: Annotated[bool, typer.Option("--synth", help="Also synthesize the answers into one unified answer.")] = False,
451
+ synthesizer: Annotated[
452
+ str,
453
+ typer.Option("--synthesizer", help="Who synthesizes: auto | random | a provider name."),
454
+ ] = "auto",
455
+ json_output: Annotated[bool, typer.Option("--json", help="Emit machine-readable JSONL.")] = False,
456
+ ) -> None:
457
+ """Ask multiple agents in parallel; answers stream back as each one finishes."""
458
+ prompt_text = _read_prompt(prompt, file)
459
+ if not prompt_text:
460
+ raise typer.BadParameter("Prompt cannot be empty.")
461
+ if num < 1:
462
+ raise typer.BadParameter("--num must be at least 1.")
463
+
464
+ models = parse_model_overrides(model)
465
+
466
+ try:
467
+ selected, skipped = select_for_run(
468
+ num, tuple(provider) if provider else None, tuple(exclude) if exclude else ()
469
+ )
470
+ except ValueError as exc:
471
+ raise typer.BadParameter(str(exc)) from exc
472
+
473
+ if not selected:
474
+ _note("No agents available. Run `moa doctor` to see which CLIs are installed.")
475
+ raise typer.Exit(code=1)
476
+
477
+ note = f"Asking {', '.join(p.name for p in selected)} (timeout {timeout:g}s)"
478
+ if skipped:
479
+ note += f"; skipped (not installed): {', '.join(skipped)}"
480
+ if exclude:
481
+ note += f"; excluded: {', '.join(exclude)}"
482
+ _note(note)
483
+
484
+ results = asyncio.run(_collect(selected, prompt_text, timeout, json_output, models))
485
+ successes = [r for r in results if r.status == "ok"]
486
+
487
+ if synth:
488
+ _run_synthesis(prompt_text, results, successes, selected, synthesizer, timeout, json_output, models)
489
+
490
+ if not successes:
491
+ raise typer.Exit(code=1)
492
+
493
+
494
+ def _run_synthesis(
495
+ prompt_text: str,
496
+ results: list[RunResult],
497
+ successes: list[RunResult],
498
+ selected: list[Provider],
499
+ synthesizer: str,
500
+ timeout: float,
501
+ json_output: bool,
502
+ models: dict[str, str] | None = None,
503
+ ) -> None:
504
+ if len(successes) < 2:
505
+ _note("Synthesis skipped: need at least 2 successful responses.")
506
+ return
507
+
508
+ candidates = [p.name for p in selected]
509
+ try:
510
+ synth_name = choose_synthesizer(synthesizer, candidates)
511
+ except ValueError as exc:
512
+ _note(f"Synthesis skipped: {exc}")
513
+ return
514
+
515
+ # Synthesis always anonymizes + shuffles its input so the synthesizer can't
516
+ # favour a brand. The A/B/C labels stay internal; the human already sees real
517
+ # names on the response blocks above.
518
+ synth_prompt, _label_map = build_synthesis_prompt(prompt_text, results, blind=True)
519
+ _note(f"Synthesizing with {synth_name}...")
520
+ synth_model = (models or {}).get(synth_name)
521
+ synth_result = asyncio.run(run_provider(PROVIDERS[synth_name], synth_prompt, timeout, synth_model))
522
+
523
+ if json_output:
524
+ _emit(json.dumps(synthesis_record(synth_result, synth_name)))
525
+ else:
526
+ _emit(render_synthesis_block(synth_result, synth_name))
527
+
528
+
529
+ @app.command()
530
+ def doctor() -> None:
531
+ """Show which agent CLIs are installed."""
532
+ available = available_provider_names()
533
+ missing = missing_provider_names()
534
+
535
+ def fmt(names: list[str]) -> str:
536
+ return ", ".join(f"{name} ({PROVIDERS[name].executable})" for name in names) or "none"
537
+
538
+ typer.echo("Available agents: " + fmt(available))
539
+ typer.echo("Missing agents: " + fmt(missing))
540
+
541
+
542
+ def main() -> None:
543
+ app()
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.3
2
+ Name: moa-cli
3
+ Version: 0.1.0
4
+ Summary: Ask one question to multiple local AI coding CLIs in parallel and collect their answers.
5
+ Keywords: llm,agents,cli,claude,codex,agy,opencode,peer-review
6
+ Author: Paul-Louis Pröve
7
+ Author-email: Paul-Louis Pröve <plp@workgenius.com>
8
+ Requires-Dist: typer>=0.25.0
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ <p align="center">
13
+ <img src="assets/logo-full.png" alt="moa - mixture of agents" width="360">
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://github.com/pietz/moa-cli/actions/workflows/ci.yml"><img src="https://github.com/pietz/moa-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
18
+ </p>
19
+
20
+ # MOA - Mixture of Agents
21
+
22
+ Ask one question to multiple local AI coding CLIs **in parallel** and collect their answers. MOA detects which agent CLIs you have installed (Claude Code, Codex, agy, opencode), fans your prompt out to them, and streams each answer back the moment that agent finishes. Optionally, it can synthesize the answers into a single unified response.
23
+
24
+ It's a drop-in, batteries-included replacement for hand-rolling parallel `claude -p` / `codex exec` / `opencode run` calls (or a "peer review" agent skill): one command, clean attributed output, made to be called by a human **or** by another agent.
25
+
26
+ The package is named `moa-cli` but installs the command `moa`.
27
+
28
+ ```bash
29
+ uv tool install moa-cli
30
+ moa ask "Is Postgres or SQLite better for a desktop app?"
31
+ ```
32
+
33
+ Or run it once without installing:
34
+
35
+ ```bash
36
+ uvx --from moa-cli moa ask "Review this plan."
37
+ ```
38
+
39
+ ## Why
40
+
41
+ A single model gives you one perspective. Asking three frontier models the same question - and seeing where they agree, diverge, or contradict - is a fast, cheap way to pressure-test an answer. MOA makes that a one-liner using the CLIs you already pay for, with no API keys of its own.
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ moa doctor # show which agent CLIs are installed
47
+ moa ask "Should this feature use SQLite?" # ask the top 3 installed agents
48
+ moa ask -n 2 "..." # ask only the top 2 (priority order)
49
+ moa ask -p claude -p agy "..." # pin specific agents
50
+ moa ask -x claude "..." # drop an agent (e.g. exclude the caller's own model)
51
+ moa ask -m claude=sonnet "..." # override which model a tool uses
52
+ moa ask --synth "..." # also merge the answers into one
53
+ moa ask --json "..." # machine-readable JSONL (for agents/pipes)
54
+ git diff | moa ask -f - "Review this diff." # read the prompt from stdin
55
+ ```
56
+
57
+ ### How agents are selected
58
+
59
+ `-n/--num` (default 3) picks the first N **installed** agents from a popularity-ordered priority list:
60
+
61
+ ```
62
+ claude -> codex -> agy -> opencode
63
+ ```
64
+
65
+ So `moa ask -n 3` on a machine with all four installed asks Claude, Codex, and agy. Use `-p/--provider` (repeatable) to pin an exact set and ignore `-n`.
66
+
67
+ Use `-x/--exclude` (repeatable) to drop one or more agents from the run. Exclusion is applied *before* `-n` takes the first N, and it also drops excluded names from an explicit `-p` set. It is off by default. The motivating case: an agent (e.g. Claude Code) calls `moa` for *other* opinions; `moa ask -x claude` makes sure one "peer" isn't just the caller's own model. So `moa ask -n 3 -x claude` asks Codex, agy, and opencode.
68
+
69
+ ### Choosing models
70
+
71
+ Each tool ships with a reasonable default model, but you can override which model any tool uses with `-m/--model PROVIDER=MODEL` (repeatable). Only the providers you name change; the rest keep their defaults.
72
+
73
+ ```bash
74
+ moa ask -m claude=sonnet -m agy="Gemini 3.1 Pro (Low)" "..."
75
+ ```
76
+
77
+ The model-string format differs per tool and is passed through verbatim (the tool's own CLI validates it):
78
+
79
+ | Provider | Default | `-m` format |
80
+ | ---------- | ----------------------- | ------------------------------------------------------ |
81
+ | `claude` | `opus` | short id, e.g. `claude=sonnet` |
82
+ | `codex` | `gpt-5.5` | model id, e.g. `codex=gpt-5.5` |
83
+ | `agy` | `Gemini 3.1 Pro (High)` | exact display name, e.g. `agy="Gemini 3.1 Pro (Low)"` |
84
+ | `opencode` | (tool's authed default) | `provider/model` slug, e.g. `opencode=anthropic/claude-sonnet-4` |
85
+
86
+ `opencode` has no built-in default; without an override it omits `-m` and lets opencode pick. Pass `-m opencode=provider/model` to pin one.
87
+
88
+ ### Output
89
+
90
+ - **stdout** carries only content: each agent's answer as a Markdown block (`## claude (opus) - OK - 3.5s`), flushed the instant that agent finishes, then the synthesis block if `--synth` is set.
91
+ - **stderr** carries progress and selection notes (`Asking claude, codex ...`), so piping stdout stays clean.
92
+ - `--json` emits one JSON object per line (JSONL): a `{"type": "response", ...}` record per agent as it completes, then a `{"type": "synthesis", ...}` record. Ideal when another agent calls MOA and parses the result.
93
+
94
+ ### Synthesis
95
+
96
+ `--synth` runs one more pass that merges the collected answers into a single, unified answer. The synthesizer is chosen with `--synthesizer`:
97
+
98
+ - `auto` (default) - the highest-priority agent that ran (deterministic)
99
+ - `random` - pick one of the agents that ran, at random
100
+ - a provider name (`claude`, `codex`, `agy`, `opencode`)
101
+
102
+ ### Attribution policy
103
+
104
+ The human (or agent) reading MOA's output **always gets correct attribution**: every response block shows the real provider name. There is no human-facing anonymization toggle.
105
+
106
+ The synthesizer is a different story. To stop it picking favourites by brand, it **always** receives the proposer answers anonymized as "Response A / B / C" and order-shuffled. This is always-on internal behaviour, not a flag. The synthesized answer itself is brand-agnostic prose, and the A/B/C labels never leak into stdout, stderr, or the JSON.
107
+
108
+ ## Supported agents
109
+
110
+ | Provider | CLI | Invocation |
111
+ | ----------- | ---------- | --------------------------------------------------- |
112
+ | `claude` | `claude` | `claude --model opus -p PROMPT` |
113
+ | `codex` | `codex` | `codex exec -m gpt-5.5 --skip-git-repo-check PROMPT`|
114
+ | `agy` | `agy` | `agy --model "Gemini 3.1 Pro (High)" -p PROMPT` |
115
+ | `opencode` | `opencode` | `opencode run PROMPT` |
116
+
117
+ Adding a new agent is a single entry in the `PROVIDERS` table in `src/moa_cli/cli.py` (executable, default model, command builder); it then participates in detection, `-n` selection, and synthesis automatically.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ uv sync
123
+ uv run pytest
124
+ uv run ruff check src tests
125
+ ```
126
+
127
+ MIT licensed.
@@ -0,0 +1,6 @@
1
+ moa_cli/__init__.py,sha256=FFvRNhGlaMi7lSjgh-MfCNjM55tL7_EjHs-YN6riD7c,46
2
+ moa_cli/cli.py,sha256=KIRtAbL0q0Vi3N1CMlcQltLK5emmMJMk4BQIHXBQ2wo,20223
3
+ moa_cli-0.1.0.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
4
+ moa_cli-0.1.0.dist-info/entry_points.txt,sha256=Rh5jxj3Y12DjL1FboVenVOWzBPJN7V-AtAn2-rdIVuA,42
5
+ moa_cli-0.1.0.dist-info/METADATA,sha256=NiTcmPDpWKxzBQjEkA1zyuQD6H_MjlN9GJVY3NCPEDg,6770
6
+ moa_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.23
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ moa = moa_cli.cli:main
3
+