roundtable-cli 0.4.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.
roundtable/cli.py ADDED
@@ -0,0 +1,722 @@
1
+ """Command-line interface.
2
+
3
+ roundtable init [dir] scaffold .roundtable/ + default config;
4
+ lists installed CLIs and their models
5
+ roundtable agents list configured agents + their models
6
+ roundtable map scan an existing project into docs + a PRD
7
+ roundtable plan --goal "..." plan from a goal
8
+ roundtable plan --prd FILE plan from a PRD / requirements file
9
+ roundtable plan --plan FILE ingest an existing plan (JSON or doc)
10
+ roundtable approve approve the plan (the human gate)
11
+ roundtable run execute all phases autonomously
12
+ (live web dashboard + inline progress)
13
+ roundtable status show phase/task progress
14
+ roundtable dashboard live web dashboard (stdlib http.server)
15
+ roundtable watch live terminal dashboard
16
+ roundtable mcp start the MCP server (stdio)
17
+
18
+ Designed to run *inside an existing project*: all roundtable artifacts live under
19
+ ``.roundtable/`` and agents run with the project directory as their working dir, so
20
+ they read and edit the real files. After ``plan``, review ``.roundtable/plan/PLAN.md``;
21
+ ``run`` refuses until ``approve``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import asyncio
28
+ import datetime as _dt
29
+ import os
30
+ import signal
31
+ import shutil
32
+ import sys
33
+ import threading
34
+ import time
35
+ from pathlib import Path
36
+
37
+ from . import runctl
38
+ from .agents import Analyst, Planner
39
+ from .config import Config, load_config, write_default_config
40
+ from .dashboard import make_server
41
+ from .discovery import AgentStatus, discover
42
+ from .engine import Engine, normalize_runner_refs
43
+ from .errors import RoundtableError
44
+ from .insights import build_state, render_text
45
+ from . import modelpick
46
+ from .llm import CLIProvider, LiteLLMProvider, LLMProvider, PiProvider, ScriptedProvider
47
+ from .models import AgentRef, Plan, Status
48
+ from .scan import build_digest
49
+ from .store import Store
50
+
51
+
52
+ def build_provider(config: Config, cwd: Path | str | None = None) -> LLMProvider:
53
+ if config.provider == "pi":
54
+ return PiProvider(
55
+ config.pi, cwd=cwd,
56
+ timeout=config.defaults.timeout, max_retries=config.defaults.max_retries,
57
+ )
58
+ if config.provider == "cli":
59
+ return CLIProvider(
60
+ config.agents, cwd=cwd,
61
+ timeout=config.defaults.timeout, max_retries=config.defaults.max_retries,
62
+ )
63
+ if config.provider == "scripted":
64
+ return ScriptedProvider()
65
+ if config.provider == "litellm":
66
+ return LiteLLMProvider(max_retries=config.defaults.max_retries)
67
+ raise RoundtableError(
68
+ f"unknown provider {config.provider!r} (use 'pi', 'cli', 'litellm', or 'scripted')"
69
+ )
70
+
71
+
72
+ def _role_models(config: Config) -> dict[str, AgentRef]:
73
+ return {
74
+ "planner": config.models.planner,
75
+ "main": config.models.main,
76
+ "phase": config.models.phase,
77
+ "task": config.models.task,
78
+ }
79
+
80
+
81
+ def _first_line(text: str, default: str = "") -> str:
82
+ for line in text.splitlines():
83
+ s = line.strip().lstrip("# ").strip()
84
+ if s:
85
+ return s[:120]
86
+ return default
87
+
88
+
89
+ async def make_plan(
90
+ store: Store,
91
+ config: Config,
92
+ *,
93
+ goal: str | None = None,
94
+ prd_path: str | None = None,
95
+ plan_path: str | None = None,
96
+ planner_model: str | None = None,
97
+ ) -> Plan:
98
+ roles = _role_models(config)
99
+ allowed = sorted({str(r) for r in roles.values()})
100
+ provider = build_provider(config, store.root)
101
+ planner_ref = AgentRef.model_validate(planner_model) if planner_model else roles["planner"]
102
+ planner = Planner(provider, planner_ref, config.defaults.temperature)
103
+
104
+ source_text = ""
105
+ if plan_path:
106
+ source_text = Path(plan_path).read_text()
107
+ try:
108
+ plan = Plan.model_validate_json(source_text) # already in our schema -> no LLM
109
+ except Exception:
110
+ plan = await planner.structure_plan(source_text, allowed, roles) # convert via LLM
111
+ elif prd_path or goal:
112
+ prd_text = Path(prd_path).read_text() if prd_path else ""
113
+ source_text = prd_text
114
+ combined = "\n\n".join(x for x in [goal or "", prd_text] if x).strip()
115
+ plan = await planner.create_plan(combined, allowed, roles)
116
+ else:
117
+ raise RoundtableError("nothing to plan from: pass --goal, --prd, or --plan")
118
+
119
+ # Backfill metadata + role defaults; normalize indices; re-validate the graph.
120
+ if goal:
121
+ plan.goal = goal.strip()
122
+ if not plan.goal:
123
+ plan.goal = _first_line(source_text, "(imported plan)")
124
+ plan.created_at = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
125
+ plan.main_runner = plan.main_runner or roles["main"]
126
+ plan.runners = roles
127
+ for i, phase in enumerate(plan.phases, start=1):
128
+ phase.index = i
129
+ phase.runner = phase.runner or roles["phase"]
130
+ for task in phase.tasks:
131
+ task.runner = task.runner or roles["task"]
132
+ normalize_runner_refs(plan, config)
133
+ plan = Plan.model_validate(plan.model_dump()) # re-run validators after edits
134
+
135
+ archive_dir = store.archive_current_run()
136
+ if archive_dir:
137
+ print(f"archived previous run → {archive_dir.relative_to(store.root)}")
138
+
139
+ store.scaffold()
140
+ store.write_brief(plan.goal if not source_text else source_text)
141
+ store.save_plan(plan)
142
+ store.write_plan_md(plan)
143
+ return plan
144
+
145
+
146
+ async def map_project(
147
+ store: Store,
148
+ config: Config,
149
+ *,
150
+ target: Path,
151
+ analyst_model: str | None = None,
152
+ max_files: int = 400,
153
+ max_bytes: int = 60000,
154
+ ) -> tuple[str, str]:
155
+ """Scan ``target`` and write ARCHITECTURE.md + PRD.md to ``.roundtable/docs/``."""
156
+ provider = build_provider(config, target) # cwd = scanned project
157
+ ref = AgentRef.model_validate(analyst_model) if analyst_model else config.models.main
158
+ store.scaffold()
159
+ digest = build_digest(target, max_files=max_files, max_total_bytes=max_bytes)
160
+ store.record_event(
161
+ "map_started", message=f"mapping {target}", target=str(target), digest_bytes=len(digest)
162
+ )
163
+ analyst = Analyst(provider, ref, config.defaults.temperature)
164
+ arch = await analyst.architecture(digest)
165
+ store.write_doc("ARCHITECTURE.md", arch)
166
+ prd = await analyst.prd(digest, arch)
167
+ store.write_doc("PRD.md", prd)
168
+ store.record_event("map_done", message="map complete", docs=["ARCHITECTURE.md", "PRD.md"])
169
+ return arch, prd
170
+
171
+
172
+ # --------------------------------------------------------------------------- #
173
+ # Commands
174
+ # --------------------------------------------------------------------------- #
175
+ def _print_agents(statuses: list[AgentStatus], *, limit: int | None = None) -> None:
176
+ if not statuses:
177
+ print(" (no agents configured)")
178
+ return
179
+ for st in sorted(statuses, key=lambda s: (not s.installed, s.name)):
180
+ if not st.installed:
181
+ print(f" [ ] {st.name} ({st.binary}) — not on PATH")
182
+ elif st.models:
183
+ shown = st.models if limit is None else st.models[:limit]
184
+ print(f" [x] {st.name} ({st.binary}) — {len(st.models)} model(s):")
185
+ for m in shown:
186
+ print(f" {m}")
187
+ if limit is not None and len(st.models) > limit:
188
+ print(f" … (+{len(st.models) - limit} more — run `roundtable agents`)")
189
+ else:
190
+ print(f" [x] {st.name} ({st.binary}) — models: n/a ({st.note})")
191
+
192
+
193
+ def cmd_init(args: argparse.Namespace) -> int:
194
+ root = Path(args.dir).resolve()
195
+ root.mkdir(parents=True, exist_ok=True)
196
+ store = Store(root)
197
+ store.scaffold()
198
+ cfg_path = root / "roundtable.config.yaml"
199
+ # Roundtable works best with a pi-family agent. Prefer upstream `pi`; fall back
200
+ # to oh-my-pi (`omp`) if only that is installed.
201
+ pi_flavor = "pi" if shutil.which("pi") else ("omp" if shutil.which("omp") else None)
202
+ if cfg_path.exists() and not args.force:
203
+ print(f"config already exists: {cfg_path} (use --force to overwrite)")
204
+ else:
205
+ backend = "pi" if pi_flavor else "cli"
206
+ write_default_config(root, backend=backend, flavor=pi_flavor or "pi")
207
+ print(f"wrote {cfg_path} (provider: {backend}" + (f", flavor: {pi_flavor})" if pi_flavor else ")"))
208
+ if pi_flavor:
209
+ tool = "omp" if pi_flavor == "omp" else "pi"
210
+ print(f"\ndetected `{tool}` on PATH → scaffolded the recommended pi backend (flavor: {pi_flavor}).")
211
+ print(f"connect an LLM to {tool} (pick one per provider you use):")
212
+ print(" • set a key: export ANTHROPIC_API_KEY=… (or OPENAI_API_KEY / GEMINI_API_KEY / …)")
213
+ print(f" • or use {tool}'s own login (e.g. `pi-ai login <provider>` for pi)")
214
+ hint = "`pi --list-models`" if tool == "pi" else "`omp --help`"
215
+ print(f"see assignable models ({hint}), then edit `models:` in the config.")
216
+ else:
217
+ print("\nno pi-family agent (`pi` or `omp`) on PATH. Two ways to run roundtable:")
218
+ print(" 1) Recommended — install pi (or oh-my-pi) and connect it to your LLMs, then re-run `roundtable init`:")
219
+ print(" npm install -g @earendil-works/pi-coding-agent # upstream pi")
220
+ print(" npm install -g @oh-my-pi/pi-coding-agent # oh-my-pi (omp): LSP/DAP/subagents")
221
+ print(" # then: set ANTHROPIC_API_KEY / OPENAI_API_KEY / … (or the tool's login)")
222
+ print(" 2) Use what you already have — the scaffolded `provider: cli` config drives the")
223
+ print(" terminal CLIs you've installed (claude, codex, gemini, …); or set `provider: litellm`")
224
+ print(" for direct API calls. Edit roundtable.config.yaml to pick each role's agent/model.")
225
+ print(f"\ninitialized roundtable in {root} (artifacts under {store.base})")
226
+
227
+ config = load_config(root)
228
+ if not args.no_models and config.provider in ("pi", "cli"):
229
+ if sys.stdin.isatty():
230
+ try:
231
+ ans = input("\nselect a model for each role now? [Y/n] ").strip().lower()
232
+ except (EOFError, KeyboardInterrupt):
233
+ ans = "n"
234
+ if ans in ("", "y", "yes"):
235
+ groups = modelpick.list_model_groups(config, timeout=args.models_timeout)
236
+ current = modelpick.current_refs(config)
237
+ picks = modelpick.pick_models(groups, current)
238
+ if picks:
239
+ modelpick.update_config_models(cfg_path, {**current, **picks})
240
+ print("updated `models:` in the config.")
241
+ else:
242
+ print("kept the scaffolded models — change any time with `roundtable models`.")
243
+ elif config.provider == "cli":
244
+ print("\navailable agents & models (probing installed CLIs — --no-models to skip):")
245
+ statuses = asyncio.run(discover(config.agents, timeout=args.models_timeout))
246
+ _print_agents(statuses, limit=8)
247
+ print("\nassign an agent:model to each role under `models:` (or run `roundtable models`).")
248
+
249
+ print("next: edit roundtable.config.yaml (or `roundtable models`), then "
250
+ "`roundtable plan --goal \"...\"` (or --prd FILE / --plan FILE)")
251
+ return 0
252
+
253
+
254
+ def cmd_agents(args: argparse.Namespace) -> int:
255
+ root = Path(args.project).resolve()
256
+ config = load_config(root)
257
+ if config.provider != "cli":
258
+ print(f"provider is {config.provider!r}; agent discovery applies to `provider: cli`.")
259
+ return 0
260
+ statuses = asyncio.run(discover(config.agents, timeout=args.timeout))
261
+ if args.json:
262
+ from dataclasses import asdict
263
+ import json
264
+ print(json.dumps([asdict(s) for s in statuses], indent=2))
265
+ else:
266
+ _print_agents(statuses, limit=None)
267
+ return 0
268
+
269
+
270
+ def _print_model_groups(groups: list[modelpick.ModelGroup]) -> None:
271
+ if not groups:
272
+ print(" (no model listing for this provider)")
273
+ return
274
+ for g in groups:
275
+ if not g.choices:
276
+ print(f" {g.name}: {g.note or 'no models'}")
277
+ continue
278
+ print(f" {g.name} ({len(g.choices)} model(s)):")
279
+ for c in g.choices:
280
+ print(f" {c.label}")
281
+
282
+
283
+ def cmd_models(args: argparse.Namespace) -> int:
284
+ root = Path(args.project).resolve()
285
+ config = load_config(root)
286
+ if config.provider not in ("pi", "cli"):
287
+ print(f"provider is {config.provider!r}; `roundtable models` applies to 'pi' or 'cli'.")
288
+ return 0
289
+ groups = modelpick.list_model_groups(config, timeout=args.timeout)
290
+ if args.json:
291
+ import json
292
+ payload = [
293
+ {"provider": g.name, "installed": g.installed, "note": g.note,
294
+ "models": [{"label": c.label, "ref": c.ref.model_dump()} for c in g.choices]}
295
+ for g in groups
296
+ ]
297
+ print(json.dumps(payload, indent=2))
298
+ return 0
299
+ if args.list:
300
+ _print_model_groups(groups)
301
+ return 0
302
+ if not sys.stdin.isatty():
303
+ print("not a TTY — use `roundtable models --list` or `--json`, or run in an interactive terminal.")
304
+ return 1
305
+ current = modelpick.current_refs(config)
306
+ picks = modelpick.pick_models(groups, current)
307
+ if not picks:
308
+ print("no changes made.")
309
+ return 0
310
+ merged = {**current, **picks}
311
+ modelpick.update_config_models(root / "roundtable.config.yaml", merged)
312
+ print(f"\nupdated `models:` in {root / 'roundtable.config.yaml'}:")
313
+ for r in modelpick.ROLES:
314
+ print(f" {r}: {modelpick.fmt_ref(merged.get(r))}")
315
+ if args.verify:
316
+ modelpick.verify_refs(config, picks)
317
+ return 0
318
+
319
+
320
+ def cmd_map(args: argparse.Namespace) -> int:
321
+ root = Path(args.project).resolve()
322
+ target = Path(args.target).resolve() if args.target else root
323
+ config = load_config(root)
324
+ store = Store(root)
325
+ asyncio.run(map_project(
326
+ store, config, target=target, analyst_model=args.model,
327
+ max_files=args.max_files, max_bytes=args.max_bytes,
328
+ ))
329
+ prd_path = store.docs_dir / "PRD.md"
330
+ print(f"mapped {target}")
331
+ print(f"wrote {store.docs_dir / 'ARCHITECTURE.md'} and {prd_path}")
332
+ print(f"review/edit {prd_path}, then: "
333
+ f"roundtable plan --prd {prd_path} --project {args.project}")
334
+ return 0
335
+
336
+
337
+ def cmd_plan(args: argparse.Namespace) -> int:
338
+ root = Path(args.project).resolve()
339
+ config = load_config(root)
340
+ store = Store(root)
341
+ plan = asyncio.run(make_plan(
342
+ store, config, goal=args.goal, prd_path=args.prd, plan_path=args.plan,
343
+ planner_model=args.model,
344
+ ))
345
+ print(f"planned {len(plan.phases)} phase(s) for: {plan.goal}")
346
+ for p in plan.phases:
347
+ print(f" [{p.id}] {p.title} ({len(p.tasks)} task(s), runner={p.runner})")
348
+ print(f"\nwrote {store.manifest_path}")
349
+ print(f"review {store.plan_dir / 'PLAN.md'}, then: roundtable approve --project {args.project}")
350
+ return 0
351
+
352
+
353
+ def cmd_approve(args: argparse.Namespace) -> int:
354
+ root = Path(args.project).resolve()
355
+ store = Store(root)
356
+ plan = store.load_plan()
357
+ plan.approved = True
358
+ store.save_plan(plan)
359
+ print("plan approved. run it with: roundtable run --project", args.project)
360
+ return 0
361
+
362
+
363
+ def cmd_run(args: argparse.Namespace) -> int:
364
+ root = Path(args.project).resolve()
365
+ config = load_config(root)
366
+ store = Store(root)
367
+
368
+ existing = runctl.current_run_pid(store)
369
+ if existing is not None:
370
+ print(
371
+ f"error: a run is already in progress (pid={existing}); "
372
+ "wait for it or cancel it with `roundtable stop`",
373
+ file=sys.stderr,
374
+ )
375
+ return 2
376
+
377
+ if args.approve:
378
+ # Best-effort auto-approve; a missing/invalid plan is surfaced by the engine.
379
+ try:
380
+ plan = store.load_plan()
381
+ if not plan.approved:
382
+ plan.approved = True
383
+ store.save_plan(plan)
384
+ print("plan auto-approved via --approve flag.")
385
+ except (FileNotFoundError, RoundtableError):
386
+ pass
387
+
388
+ provider = build_provider(config, root)
389
+ engine = Engine(store, config, provider)
390
+ store.write_run_pid(os.getpid()) # this process IS the run; guards double-launch
391
+ try:
392
+ return asyncio.run(_run_with_progress(engine, store, args))
393
+ except asyncio.CancelledError:
394
+ print("\nterminated — re-run `roundtable run` to resume", file=sys.stderr)
395
+ return 143
396
+ except KeyboardInterrupt:
397
+ print("\ninterrupted — re-run `roundtable run` to resume", file=sys.stderr)
398
+ return 130
399
+ finally:
400
+ store.clear_run_pid()
401
+
402
+
403
+ async def _run_with_progress(engine: Engine, store: Store, args: argparse.Namespace) -> int:
404
+ """Drive the engine while showing live progress: a web dashboard link up front
405
+ and the terminal ``watch`` view rendered inline (TTY only)."""
406
+ live = sys.stdout.isatty() and not args.no_watch
407
+
408
+ # Serve the web dashboard in a background thread so the link is live as the run
409
+ # goes. Port 0 picks a free port, side-stepping any port collision.
410
+ httpd = url = None
411
+ if not args.no_dashboard:
412
+ try:
413
+ httpd, url = make_server(store, host=args.host, port=args.port)
414
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
415
+ if args.open:
416
+ import webbrowser
417
+ webbrowser.open(url)
418
+ except RoundtableError as e:
419
+ print(f"(web dashboard unavailable: {e})", file=sys.stderr)
420
+
421
+ def banner() -> str:
422
+ web = f"dashboard: {url}" if url else "dashboard: off (--no-dashboard)"
423
+ return f"{web} · Ctrl-C to stop (resumable)"
424
+
425
+ print(banner()) # show the link immediately, before the first render
426
+
427
+ task = asyncio.create_task(engine.run())
428
+ loop = asyncio.get_running_loop()
429
+ terminated = False
430
+
431
+ def _on_sigterm() -> None:
432
+ nonlocal terminated
433
+ terminated = True
434
+ task.cancel()
435
+
436
+ try:
437
+ loop.add_signal_handler(signal.SIGTERM, _on_sigterm)
438
+ has_sigterm_handler = True
439
+ except (NotImplementedError, RuntimeError):
440
+ has_sigterm_handler = False
441
+ try:
442
+ if live:
443
+ while not task.done():
444
+ try:
445
+ frame = render_text(build_state(store))
446
+ except Exception: # transient read during an engine write
447
+ frame = "loading…"
448
+ sys.stdout.write("\x1b[2J\x1b[H" + banner() + "\n\n" + frame + "\n")
449
+ sys.stdout.flush()
450
+ await asyncio.sleep(max(0.1, args.interval))
451
+ plan = await task # awaits completion; re-raises any engine error
452
+ except asyncio.CancelledError:
453
+ # Ctrl-C: asyncio.run cancels this coroutine (KeyboardInterrupt itself
454
+ # surfaces in cmd_run, not here). Forward the cancellation to the engine
455
+ # so it can mark the manifest resumable before we go.
456
+ task.cancel()
457
+ try:
458
+ await task
459
+ except BaseException:
460
+ pass
461
+ if terminated:
462
+ print("\nterminated — re-run `roundtable run` to resume", file=sys.stderr)
463
+ return 143
464
+ raise
465
+ finally:
466
+ if has_sigterm_handler:
467
+ loop.remove_signal_handler(signal.SIGTERM)
468
+ if httpd:
469
+ httpd.shutdown()
470
+ httpd.server_close()
471
+
472
+ if live:
473
+ try:
474
+ sys.stdout.write("\x1b[2J\x1b[H" + render_text(build_state(store)) + "\n")
475
+ except Exception:
476
+ pass
477
+ print(f"\nrun complete: {len(plan.phases)} phase(s), status={plan.status.value}")
478
+ print(f"docs: {store.docs_dir}")
479
+ return 0
480
+
481
+
482
+ def cmd_status(args: argparse.Namespace) -> int:
483
+ root = Path(args.project).resolve()
484
+ store = Store(root)
485
+
486
+ if args.json:
487
+ import json as _json
488
+ state = build_state(store)
489
+ print(_json.dumps(state, indent=2))
490
+ return 0
491
+
492
+ plan = store.load_plan()
493
+ mark = {
494
+ Status.done: "[x]", Status.in_progress: "[~]", Status.pending: "[ ]",
495
+ Status.failed: "[!]", Status.skipped: "[-]", Status.waiting: "[?]",
496
+ }
497
+ print(f"goal: {plan.goal}")
498
+ print(f"role runners: {({k: str(v) for k, v in plan.runners.items()})}")
499
+ print(f"approved: {plan.approved} | status: {plan.status.value}\n")
500
+ for p in plan.phases:
501
+ print(f"{mark[p.status]} Phase {p.index}: {p.title} [{p.id}]")
502
+ for t in p.tasks:
503
+ dep = f" <- {','.join(t.depends_on)}" if t.depends_on else ""
504
+ print(f" {mark[t.status]} {t.title} [{t.id}] ({t.runner}){dep}")
505
+ return 0
506
+
507
+
508
+ def cmd_dashboard(args: argparse.Namespace) -> int:
509
+ store = Store(Path(args.project).resolve())
510
+ httpd, url = make_server(store, host=args.host, port=args.port)
511
+ print(f"dashboard live at {url} (Ctrl-C to stop)")
512
+ print("watching .roundtable/ — run `roundtable run` in another terminal to see it update")
513
+ if args.open:
514
+ import webbrowser
515
+ webbrowser.open(url)
516
+ try:
517
+ httpd.serve_forever()
518
+ except KeyboardInterrupt:
519
+ print("\nstopping dashboard")
520
+ finally:
521
+ httpd.shutdown()
522
+ httpd.server_close()
523
+ return 0
524
+
525
+
526
+ def cmd_serve(args: argparse.Namespace) -> int:
527
+ """Dashboard + REST control API with a machine-readable first line.
528
+
529
+ The desktop app spawns `roundtable serve --project X --port 0` and parses the
530
+ first stdout line (JSON) for the URL; humans get a friendly line after it.
531
+ """
532
+ import json as _json
533
+ store = Store(Path(args.project).resolve())
534
+ httpd, url = make_server(store, host=args.host, port=args.port)
535
+ print(_json.dumps({"event": "serving", "url": url, "project": str(store.root)}), flush=True)
536
+ print(f"roundtable API + dashboard live at {url} (Ctrl-C to stop)", flush=True)
537
+ try:
538
+ httpd.serve_forever()
539
+ except KeyboardInterrupt:
540
+ pass
541
+ finally:
542
+ httpd.shutdown()
543
+ httpd.server_close()
544
+ return 0
545
+
546
+
547
+ def cmd_resume(args: argparse.Namespace) -> int:
548
+ store = Store(Path(args.project).resolve())
549
+ print(runctl.approve_hitl(store, args.task))
550
+ return 0
551
+
552
+
553
+ def cmd_stop(args: argparse.Namespace) -> int:
554
+ store = Store(Path(args.project).resolve())
555
+ stopped, msg = runctl.stop_run(store)
556
+ print(msg)
557
+ return 0 if stopped else 1
558
+
559
+
560
+ def cmd_mcp(args: argparse.Namespace) -> int:
561
+ try:
562
+ from .mcp import run_server
563
+ except ImportError:
564
+ print("error: 'mcp' package not installed — run: pip install 'roundtable-cli[mcp]'",
565
+ file=sys.stderr)
566
+ return 2
567
+ run_server()
568
+ return 0
569
+
570
+
571
+ def cmd_watch(args: argparse.Namespace) -> int:
572
+ store = Store(Path(args.project).resolve())
573
+ if not store.has_plan():
574
+ raise RoundtableError("no plan to watch; run `roundtable plan` first")
575
+ try:
576
+ while True:
577
+ state = build_state(store)
578
+ sys.stdout.write("\x1b[2J\x1b[H") # clear screen + home
579
+ sys.stdout.write(render_text(state) + "\n")
580
+ sys.stdout.flush()
581
+ if state.get("status") == "done" and not args.follow:
582
+ break
583
+ time.sleep(args.interval)
584
+ except KeyboardInterrupt:
585
+ pass
586
+ return 0
587
+
588
+
589
+ def build_parser() -> argparse.ArgumentParser:
590
+ p = argparse.ArgumentParser(prog="roundtable", description="Multi-LLM planning & orchestration tool")
591
+ p.add_argument("-v", "--verbose", action="store_true", help="enable verbose/debug logging")
592
+ sub = p.add_subparsers(dest="command", required=True)
593
+
594
+ sp = sub.add_parser("init", help="scaffold .roundtable/ + default config")
595
+ sp.add_argument("dir", nargs="?", default=".")
596
+ sp.add_argument("--force", action="store_true", help="overwrite an existing config")
597
+ sp.add_argument("--no-models", action="store_true",
598
+ help="skip probing installed CLIs for their model lists")
599
+ sp.add_argument("--models-timeout", type=float, default=15.0,
600
+ help="seconds to wait per CLI when listing models (default 15)")
601
+ sp.set_defaults(func=cmd_init)
602
+
603
+ sp = sub.add_parser("agents", help="list configured agents, which are installed, and their models")
604
+ sp.add_argument("--project", default=".")
605
+ sp.add_argument("--timeout", type=float, default=20.0,
606
+ help="seconds to wait per CLI when listing models (default 20)")
607
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
608
+ sp.set_defaults(func=cmd_agents)
609
+
610
+ sp = sub.add_parser("models", help="list connected models and pick one per role (pi/omp or cli)")
611
+ sp.add_argument("--project", default=".")
612
+ sp.add_argument("--list", action="store_true", help="just print available models, don't pick")
613
+ sp.add_argument("--json", action="store_true", help="machine-readable model list")
614
+ sp.add_argument("--verify", action="store_true",
615
+ help="after picking, send one tiny call to each chosen model to confirm it works")
616
+ sp.add_argument("--timeout", type=float, default=20.0,
617
+ help="seconds for the model-list query (default 20)")
618
+ sp.set_defaults(func=cmd_models)
619
+
620
+ sp = sub.add_parser("map", help="scan an existing project into outline docs + a PRD to confirm")
621
+ sp.add_argument("--project", default=".", help="roundtable root (where .roundtable/ lives)")
622
+ sp.add_argument("--target", default=None,
623
+ help="codebase to scan (default: the project root)")
624
+ sp.add_argument("--model", default=None,
625
+ help="override the analyst agent/model (default: the 'main' role)")
626
+ sp.add_argument("--max-files", type=int, default=400,
627
+ help="max files to include in the scan digest (default 400)")
628
+ sp.add_argument("--max-bytes", type=int, default=60000,
629
+ help="max total bytes of file contents in the digest (default 60000)")
630
+ sp.set_defaults(func=cmd_map)
631
+
632
+ sp = sub.add_parser("plan", help="generate or import a plan")
633
+ sp.add_argument("--goal", default=None, help="goal text")
634
+ sp.add_argument("--prd", default=None, help="path to a PRD / requirements file")
635
+ sp.add_argument("--plan", default=None, help="path to an existing plan (JSON schema or free-form)")
636
+ sp.add_argument("--project", default=".")
637
+ sp.add_argument("--model", default=None, help="override the planner model/agent")
638
+ sp.set_defaults(func=cmd_plan)
639
+
640
+ sp = sub.add_parser("approve", help="approve the plan (human gate)")
641
+ sp.add_argument("--project", default=".")
642
+ sp.set_defaults(func=cmd_approve)
643
+
644
+ sp = sub.add_parser("run", help="execute the approved plan (live progress + web dashboard)")
645
+ sp.add_argument("--project", default=".")
646
+ sp.add_argument("--approve", action="store_true", help="auto-approve the plan before running")
647
+ sp.add_argument("--no-dashboard", action="store_true",
648
+ help="don't serve the live web dashboard during the run")
649
+ sp.add_argument("--no-watch", action="store_true",
650
+ help="don't render the live terminal view (just run)")
651
+ sp.add_argument("--interval", type=float, default=1.0,
652
+ help="live terminal refresh seconds (default 1)")
653
+ sp.add_argument("--host", default="127.0.0.1", help="dashboard bind address (localhost only)")
654
+ sp.add_argument("--port", type=int, default=0,
655
+ help="dashboard port (0 = pick a free port)")
656
+ sp.add_argument("--open", action="store_true", help="open the dashboard in a browser")
657
+ sp.set_defaults(func=cmd_run)
658
+
659
+ sp = sub.add_parser("status", help="show phase/task progress")
660
+ sp.add_argument("--project", default=".")
661
+ sp.add_argument("--json", action="store_true", help="output status as JSON")
662
+ sp.set_defaults(func=cmd_status)
663
+
664
+ sp = sub.add_parser("dashboard", help="serve a live web dashboard of the run")
665
+ sp.add_argument("--project", default=".")
666
+ sp.add_argument("--host", default="127.0.0.1", help="bind address (localhost only)")
667
+ sp.add_argument("--port", type=int, default=8787)
668
+ sp.add_argument("--open", action="store_true", help="open the dashboard in a browser")
669
+ sp.set_defaults(func=cmd_dashboard)
670
+
671
+ sp = sub.add_parser(
672
+ "serve",
673
+ help="serve the dashboard + REST control API (machine-readable URL on stdout)",
674
+ )
675
+ sp.add_argument("--project", default=".")
676
+ sp.add_argument("--host", default="127.0.0.1", help="bind address (localhost only)")
677
+ sp.add_argument("--port", type=int, default=0, help="port (0 = pick a free port)")
678
+ sp.set_defaults(func=cmd_serve)
679
+
680
+ sp = sub.add_parser("watch", help="live terminal dashboard of the run")
681
+ sp.add_argument("--project", default=".")
682
+ sp.add_argument("--interval", type=float, default=1.0, help="refresh seconds (default 1)")
683
+ sp.add_argument("--follow", action="store_true", help="keep watching after the run completes")
684
+ sp.set_defaults(func=cmd_watch)
685
+
686
+ sp = sub.add_parser(
687
+ "resume",
688
+ help="approve a paused HITL task and let the run continue",
689
+ )
690
+ sp.add_argument("--task", required=True, help="task ID to approve (e.g. p1-t2)")
691
+ sp.add_argument("--project", default=".")
692
+ sp.set_defaults(func=cmd_resume)
693
+
694
+ sp = sub.add_parser("stop", help="stop the in-progress run/process group")
695
+ sp.add_argument("--project", default=".")
696
+ sp.set_defaults(func=cmd_stop)
697
+
698
+ sp = sub.add_parser("mcp", help="start the roundtable MCP server over stdio (requires mcp extra)")
699
+ sp.set_defaults(func=cmd_mcp)
700
+ return p
701
+
702
+
703
+ def main(argv: list[str] | None = None) -> int:
704
+ import logging
705
+ args = build_parser().parse_args(argv)
706
+ level = logging.DEBUG if getattr(args, 'verbose', False) else logging.WARNING
707
+ logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
708
+ try:
709
+ return args.func(args)
710
+ except RoundtableError as e:
711
+ print(f"error: {e}", file=sys.stderr)
712
+ return 2
713
+ except FileNotFoundError as e:
714
+ print(f"error: {e}", file=sys.stderr)
715
+ return 2
716
+ except KeyboardInterrupt:
717
+ print("\ninterrupted", file=sys.stderr)
718
+ return 130
719
+
720
+
721
+ if __name__ == "__main__":
722
+ raise SystemExit(main())