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 +3 -0
- moa_cli/cli.py +543 -0
- moa_cli-0.1.0.dist-info/METADATA +127 -0
- moa_cli-0.1.0.dist-info/RECORD +6 -0
- moa_cli-0.1.0.dist-info/WHEEL +4 -0
- moa_cli-0.1.0.dist-info/entry_points.txt +3 -0
moa_cli/__init__.py
ADDED
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,,
|