paircode 0.7.1__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.
- paircode/__init__.py +5 -0
- paircode/__main__.py +4 -0
- paircode/cli.py +419 -0
- paircode/detect.py +52 -0
- paircode/drive.py +385 -0
- paircode/gates.py +62 -0
- paircode/handshake.py +55 -0
- paircode/installer.py +161 -0
- paircode/journey.py +63 -0
- paircode/runner.py +114 -0
- paircode/seal.py +52 -0
- paircode/state.py +162 -0
- paircode/templates/FOCUS.md +35 -0
- paircode/templates/JOURNEY.md +21 -0
- paircode/templates/claude_slash_command.md +23 -0
- paircode/templates/codex_rules.md +13 -0
- paircode/templates/peers.yaml +20 -0
- paircode-0.7.1.data/data/paircode/templates/FOCUS.md +35 -0
- paircode-0.7.1.data/data/paircode/templates/JOURNEY.md +21 -0
- paircode-0.7.1.data/data/paircode/templates/claude_slash_command.md +23 -0
- paircode-0.7.1.data/data/paircode/templates/codex_rules.md +13 -0
- paircode-0.7.1.data/data/paircode/templates/peers.yaml +20 -0
- paircode-0.7.1.dist-info/METADATA +146 -0
- paircode-0.7.1.dist-info/RECORD +27 -0
- paircode-0.7.1.dist-info/WHEEL +4 -0
- paircode-0.7.1.dist-info/entry_points.txt +2 -0
- paircode-0.7.1.dist-info/licenses/LICENSE +21 -0
paircode/__init__.py
ADDED
paircode/__main__.py
ADDED
paircode/cli.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""paircode CLI entry point.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
install — register /paircode in all detected LLM CLIs (Claude, Codex, Gemini)
|
|
5
|
+
uninstall — remove paircode entries from those CLIs (idempotent)
|
|
6
|
+
handshake — list detected CLIs, no install
|
|
7
|
+
status — summarize current .paircode/ state (step B)
|
|
8
|
+
init — bootstrap .paircode/ in cwd (step B)
|
|
9
|
+
focus — open/list focuses (step B)
|
|
10
|
+
stage — run one peer-review round at a stage (step B)
|
|
11
|
+
drive — high-level workflow driver: topic → research → plan → execute (step B)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from paircode import __version__
|
|
22
|
+
from paircode.detect import detect_all
|
|
23
|
+
from paircode.drive import drive_full, drive_research, run_stage
|
|
24
|
+
from paircode.handshake import propose_roster, proposed_as_yaml_dicts
|
|
25
|
+
from paircode.installer import install_all, uninstall_all
|
|
26
|
+
from paircode.journey import note_focus_opened
|
|
27
|
+
from paircode.seal import discover_latest_versions, seal_stage
|
|
28
|
+
from paircode.state import (
|
|
29
|
+
find_paircode,
|
|
30
|
+
init_paircode,
|
|
31
|
+
open_focus,
|
|
32
|
+
read_peers,
|
|
33
|
+
write_peers,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.group(invoke_without_command=True)
|
|
41
|
+
@click.version_option(__version__, prog_name="paircode")
|
|
42
|
+
@click.pass_context
|
|
43
|
+
def main(ctx: click.Context) -> None:
|
|
44
|
+
"""paircode — adversarial journey framework for LLM peer review.
|
|
45
|
+
|
|
46
|
+
Run `paircode install` to register /paircode in your LLM CLIs.
|
|
47
|
+
Run `paircode drive "<topic>"` to kick off a research → plan → execute loop.
|
|
48
|
+
Run `paircode --help` for the full command list.
|
|
49
|
+
"""
|
|
50
|
+
if ctx.invoked_subcommand is None:
|
|
51
|
+
console.print(f"[bold]paircode[/bold] v{__version__}")
|
|
52
|
+
console.print("Run [cyan]paircode --help[/cyan] to see available commands.")
|
|
53
|
+
console.print(
|
|
54
|
+
"First-time setup: [cyan]paircode install[/cyan] "
|
|
55
|
+
"(registers /paircode in Claude, Codex, Gemini)."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@main.command()
|
|
60
|
+
def install() -> None:
|
|
61
|
+
"""Install /paircode slash command into every detected LLM CLI (global scope)."""
|
|
62
|
+
console.print("[bold]Installing paircode into detected LLM CLIs...[/bold]\n")
|
|
63
|
+
results = install_all()
|
|
64
|
+
table = Table(show_header=True, header_style="bold")
|
|
65
|
+
table.add_column("CLI")
|
|
66
|
+
table.add_column("Action")
|
|
67
|
+
table.add_column("Details")
|
|
68
|
+
for r in results:
|
|
69
|
+
color = {"installed": "green", "skipped": "yellow", "failed": "red"}.get(r.action, "white")
|
|
70
|
+
table.add_row(r.cli_name, f"[{color}]{r.action}[/{color}]", r.message)
|
|
71
|
+
console.print(table)
|
|
72
|
+
installed = sum(1 for r in results if r.action == "installed")
|
|
73
|
+
skipped = sum(1 for r in results if r.action == "skipped")
|
|
74
|
+
failed = sum(1 for r in results if r.action == "failed")
|
|
75
|
+
console.print(
|
|
76
|
+
f"\n[bold]Summary:[/bold] {installed} installed, {skipped} skipped, {failed} failed."
|
|
77
|
+
)
|
|
78
|
+
if installed > 0:
|
|
79
|
+
console.print(
|
|
80
|
+
"\nTry it: open Claude Code in any project and run [cyan]/paircode[/cyan]."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@main.command()
|
|
85
|
+
def uninstall() -> None:
|
|
86
|
+
"""Remove /paircode from every LLM CLI config dir (idempotent)."""
|
|
87
|
+
console.print("[bold]Uninstalling paircode from LLM CLIs...[/bold]\n")
|
|
88
|
+
results = uninstall_all()
|
|
89
|
+
for r in results:
|
|
90
|
+
console.print(f" [dim]{r.cli_name}:[/dim] {r.message}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@main.command()
|
|
94
|
+
@click.option("--write", is_flag=True, help="Write proposed roster to .paircode/peers.yaml")
|
|
95
|
+
def handshake(write: bool) -> None:
|
|
96
|
+
"""List detected LLM CLIs and propose a peer roster.
|
|
97
|
+
|
|
98
|
+
Without --write, just shows what was detected and what roster would be proposed.
|
|
99
|
+
With --write, saves the proposed roster to .paircode/peers.yaml (requires init first).
|
|
100
|
+
"""
|
|
101
|
+
detected = detect_all()
|
|
102
|
+
dtable = Table(title="Detected CLIs", show_header=True, header_style="bold")
|
|
103
|
+
dtable.add_column("CLI")
|
|
104
|
+
dtable.add_column("Installed")
|
|
105
|
+
dtable.add_column("Binary / Install hint")
|
|
106
|
+
for name, info in detected.items():
|
|
107
|
+
status = "[green]yes[/green]" if info.installed else "[red]no[/red]"
|
|
108
|
+
right = str(info.binary_path) if info.binary_path else f"[dim]{info.install_hint}[/dim]"
|
|
109
|
+
dtable.add_row(name, status, right)
|
|
110
|
+
console.print(dtable)
|
|
111
|
+
|
|
112
|
+
proposed = propose_roster()
|
|
113
|
+
if not proposed:
|
|
114
|
+
console.print(
|
|
115
|
+
"\n[yellow]No peers detected (only alpha is available). Install "
|
|
116
|
+
"codex/gemini/ollama to add peer voices.[/yellow]"
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
ptable = Table(title="Proposed peer roster", show_header=True, header_style="bold")
|
|
121
|
+
ptable.add_column("id")
|
|
122
|
+
ptable.add_column("cli")
|
|
123
|
+
ptable.add_column("mode")
|
|
124
|
+
ptable.add_column("priority")
|
|
125
|
+
ptable.add_column("notes")
|
|
126
|
+
for p in proposed:
|
|
127
|
+
ptable.add_row(p.id, p.cli, p.mode, p.priority, p.notes)
|
|
128
|
+
console.print(ptable)
|
|
129
|
+
|
|
130
|
+
if write:
|
|
131
|
+
state = find_paircode()
|
|
132
|
+
if state is None:
|
|
133
|
+
console.print(
|
|
134
|
+
"[red]No .paircode/ found.[/red] Run [cyan]paircode init[/cyan] first, then rerun."
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
write_peers(state, proposed_as_yaml_dicts(proposed))
|
|
138
|
+
console.print(
|
|
139
|
+
f"\n[green]✓[/green] Wrote roster to [cyan]{state.peers_path}[/cyan]"
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
console.print(
|
|
143
|
+
"\n[dim]Run `paircode handshake --write` to save this roster to "
|
|
144
|
+
".paircode/peers.yaml[/dim]"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@main.command()
|
|
149
|
+
def status() -> None:
|
|
150
|
+
"""Summarize the current .paircode/ state (walks up from cwd)."""
|
|
151
|
+
state = find_paircode()
|
|
152
|
+
if state is None:
|
|
153
|
+
console.print(
|
|
154
|
+
"[yellow]No .paircode/ found in cwd or any parent. "
|
|
155
|
+
"Run [cyan]paircode init[/cyan] to bootstrap.[/yellow]"
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
console.print(f"[bold].paircode/[/bold] at [cyan]{state.root}[/cyan]")
|
|
159
|
+
console.print(f" project root: {state.project_root}")
|
|
160
|
+
console.print(f" focuses: {state.focus_count}")
|
|
161
|
+
if state.active_focus:
|
|
162
|
+
console.print(f" active focus: [green]{state.active_focus.name}[/green]")
|
|
163
|
+
peers = read_peers(state)
|
|
164
|
+
console.print(f" peers: {len(peers)} configured")
|
|
165
|
+
if peers:
|
|
166
|
+
ptable = Table(show_header=True, header_style="bold")
|
|
167
|
+
ptable.add_column("id")
|
|
168
|
+
ptable.add_column("cli")
|
|
169
|
+
ptable.add_column("mode")
|
|
170
|
+
ptable.add_column("priority")
|
|
171
|
+
for p in peers:
|
|
172
|
+
ptable.add_row(
|
|
173
|
+
str(p.get("id", "?")),
|
|
174
|
+
str(p.get("cli", "?")),
|
|
175
|
+
str(p.get("mode", "?")),
|
|
176
|
+
str(p.get("priority", "?")),
|
|
177
|
+
)
|
|
178
|
+
console.print(ptable)
|
|
179
|
+
if state.focus_dirs:
|
|
180
|
+
ftable = Table(show_header=True, header_style="bold")
|
|
181
|
+
ftable.add_column("#")
|
|
182
|
+
ftable.add_column("focus")
|
|
183
|
+
ftable.add_column("research")
|
|
184
|
+
ftable.add_column("plan")
|
|
185
|
+
ftable.add_column("execute")
|
|
186
|
+
for i, d in enumerate(state.focus_dirs, 1):
|
|
187
|
+
per_stage_cols = []
|
|
188
|
+
for stage in ("research", "plan", "execute"):
|
|
189
|
+
stage_dir = d / stage
|
|
190
|
+
if not stage_dir.exists():
|
|
191
|
+
per_stage_cols.append("[dim]—[/dim]")
|
|
192
|
+
continue
|
|
193
|
+
latest = discover_latest_versions(stage_dir)
|
|
194
|
+
if not latest:
|
|
195
|
+
per_stage_cols.append("[dim]empty[/dim]")
|
|
196
|
+
continue
|
|
197
|
+
final_count = sum(
|
|
198
|
+
1 for pid in latest if (stage_dir / f"{pid}-FINAL.md").exists()
|
|
199
|
+
)
|
|
200
|
+
v_max = max(
|
|
201
|
+
int(re.search(r"v(\d+)", p.name).group(1)) # type: ignore[union-attr]
|
|
202
|
+
for p in latest.values()
|
|
203
|
+
)
|
|
204
|
+
sealed_note = f" ([green]{final_count} sealed[/green])" if final_count else ""
|
|
205
|
+
per_stage_cols.append(f"v{v_max}{sealed_note}")
|
|
206
|
+
ftable.add_row(str(i), d.name, *per_stage_cols)
|
|
207
|
+
console.print(ftable)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@main.command()
|
|
211
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing .paircode/ dir")
|
|
212
|
+
def init(force: bool) -> None:
|
|
213
|
+
"""Bootstrap .paircode/ in the current directory."""
|
|
214
|
+
try:
|
|
215
|
+
state = init_paircode(force=force)
|
|
216
|
+
except FileExistsError as exc:
|
|
217
|
+
console.print(f"[red]{exc}[/red]")
|
|
218
|
+
return
|
|
219
|
+
console.print(
|
|
220
|
+
f"[green]✓[/green] Initialized [cyan]{state.root}[/cyan]\n"
|
|
221
|
+
f" wrote {state.journey_path.name}\n"
|
|
222
|
+
f" wrote {state.peers_path.name}\n"
|
|
223
|
+
"\nNext: [cyan]paircode handshake[/cyan] to detect LLM CLIs and propose a peer roster,"
|
|
224
|
+
"\nor [cyan]paircode focus <name>[/cyan] to open your first focus."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@main.command()
|
|
229
|
+
@click.argument("name", required=False)
|
|
230
|
+
@click.option("--prompt", "-p", default=None, help="One-line focus prompt (freeform)")
|
|
231
|
+
def focus(name: str | None, prompt: str | None) -> None:
|
|
232
|
+
"""Open a new focus, or list existing focuses if no name given."""
|
|
233
|
+
state = find_paircode()
|
|
234
|
+
if state is None:
|
|
235
|
+
console.print(
|
|
236
|
+
"[red]No .paircode/ found.[/red] Run [cyan]paircode init[/cyan] first."
|
|
237
|
+
)
|
|
238
|
+
return
|
|
239
|
+
if not name:
|
|
240
|
+
if not state.focus_dirs:
|
|
241
|
+
console.print("[yellow]No focuses yet.[/yellow]")
|
|
242
|
+
return
|
|
243
|
+
for d in state.focus_dirs:
|
|
244
|
+
marker = "→" if d == state.active_focus else " "
|
|
245
|
+
console.print(f" {marker} {d.name}")
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
focus_dir = open_focus(state, name, prompt=prompt)
|
|
249
|
+
except FileExistsError as exc:
|
|
250
|
+
console.print(f"[red]{exc}[/red]")
|
|
251
|
+
return
|
|
252
|
+
note_focus_opened(state, focus_dir.name)
|
|
253
|
+
console.print(f"[green]✓[/green] Opened focus [cyan]{focus_dir.name}[/cyan]")
|
|
254
|
+
console.print(f" Edit: [dim]{focus_dir}/FOCUS.md[/dim]")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@main.command()
|
|
258
|
+
@click.argument("stage_name", type=click.Choice(["research", "plan", "execute"]))
|
|
259
|
+
def seal(stage_name: str) -> None:
|
|
260
|
+
"""Seal the given stage on the active focus: copy each peer's latest version to {peer}-FINAL.md."""
|
|
261
|
+
state = find_paircode()
|
|
262
|
+
if state is None or state.active_focus is None:
|
|
263
|
+
console.print(
|
|
264
|
+
"[red]No active focus.[/red] Run [cyan]paircode focus <name>[/cyan] first."
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
stage_dir = state.active_focus / stage_name
|
|
268
|
+
if not stage_dir.exists():
|
|
269
|
+
console.print(f"[red]Stage dir not found: {stage_dir}[/red]")
|
|
270
|
+
return
|
|
271
|
+
sealed = seal_stage(stage_dir)
|
|
272
|
+
if not sealed:
|
|
273
|
+
console.print(
|
|
274
|
+
f"[yellow]No versioned files found in {stage_dir} — nothing to seal.[/yellow]"
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
for s in sealed:
|
|
278
|
+
console.print(
|
|
279
|
+
f" [green]✓[/green] {s.peer_id}: {s.source.name} → [cyan]{s.final.name}[/cyan]"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@main.command()
|
|
284
|
+
@click.argument("stage_name", type=click.Choice(["research", "plan", "execute"]))
|
|
285
|
+
@click.option("--rounds", default=1, show_default=True, help="Rounds to run (round 1 = cold v1; 2+ = review+revise)")
|
|
286
|
+
@click.option("--alpha-cli", default="claude", show_default=True)
|
|
287
|
+
@click.option("--alpha-model", default=None)
|
|
288
|
+
@click.option("--timeout", default=600, show_default=True)
|
|
289
|
+
def stage(stage_name: str, rounds: int, alpha_cli: str, alpha_model: str | None, timeout: int) -> None:
|
|
290
|
+
"""Run a peer-review stage on the active focus for N rounds."""
|
|
291
|
+
from paircode.state import find_paircode
|
|
292
|
+
|
|
293
|
+
state = find_paircode()
|
|
294
|
+
if state is None or state.active_focus is None:
|
|
295
|
+
console.print("[red]No active focus.[/red] Run [cyan]paircode focus <name>[/cyan] first.")
|
|
296
|
+
return
|
|
297
|
+
focus_dir = state.active_focus
|
|
298
|
+
focus_md = focus_dir / "FOCUS.md"
|
|
299
|
+
topic = focus_md.read_text(encoding="utf-8") if focus_md.exists() else focus_dir.name
|
|
300
|
+
|
|
301
|
+
console.print(
|
|
302
|
+
f"[bold]stage[/bold] {stage_name} × {rounds} rounds on [cyan]{focus_dir.name}[/cyan]"
|
|
303
|
+
)
|
|
304
|
+
results = run_stage(
|
|
305
|
+
topic=topic,
|
|
306
|
+
focus_dir=focus_dir,
|
|
307
|
+
stage=stage_name, # type: ignore[arg-type]
|
|
308
|
+
rounds=rounds,
|
|
309
|
+
alpha_cli=alpha_cli,
|
|
310
|
+
alpha_model=alpha_model,
|
|
311
|
+
timeout_s=timeout,
|
|
312
|
+
state=state,
|
|
313
|
+
)
|
|
314
|
+
_render_stage_results(stage_name, results)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _render_stage_results(stage_name: str, results) -> None:
|
|
318
|
+
for sr in results:
|
|
319
|
+
console.print(f"\n[bold]round {sr.version}[/bold] ({stage_name})")
|
|
320
|
+
table = Table(show_header=True, header_style="bold")
|
|
321
|
+
table.add_column("kind")
|
|
322
|
+
table.add_column("peer")
|
|
323
|
+
table.add_column("cli")
|
|
324
|
+
table.add_column("ok")
|
|
325
|
+
table.add_column("duration")
|
|
326
|
+
if sr.peer_results:
|
|
327
|
+
for r in sr.peer_results:
|
|
328
|
+
status = "[green]✓[/green]" if r.ok else "[red]✗[/red]"
|
|
329
|
+
table.add_row("cold", r.peer_id, r.cli, status, f"{r.duration_s:.1f}s")
|
|
330
|
+
if sr.review_results:
|
|
331
|
+
for r in sr.review_results:
|
|
332
|
+
status = "[green]✓[/green]" if r.ok else "[red]✗[/red]"
|
|
333
|
+
table.add_row("review", r.peer_id, r.cli, status, f"{r.duration_s:.1f}s")
|
|
334
|
+
if sr.alpha_revision:
|
|
335
|
+
r = sr.alpha_revision
|
|
336
|
+
status = "[green]✓[/green]" if r.ok else "[red]✗[/red]"
|
|
337
|
+
table.add_row("revise", r.peer_id, r.cli, status, f"{r.duration_s:.1f}s")
|
|
338
|
+
console.print(table)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@main.command()
|
|
342
|
+
@click.argument("topic")
|
|
343
|
+
@click.option(
|
|
344
|
+
"--alpha-cli",
|
|
345
|
+
default="claude",
|
|
346
|
+
show_default=True,
|
|
347
|
+
help="Which LLM CLI acts as alpha (the primary developer)",
|
|
348
|
+
)
|
|
349
|
+
@click.option("--alpha-model", default=None, help="Model string for alpha (CLI default if omitted)")
|
|
350
|
+
@click.option("--timeout", default=600, show_default=True, help="Per-peer timeout in seconds")
|
|
351
|
+
@click.option(
|
|
352
|
+
"--research-rounds",
|
|
353
|
+
default=2,
|
|
354
|
+
show_default=True,
|
|
355
|
+
help="Rounds in research stage (1 = cold only, 2+ = with reviews)",
|
|
356
|
+
)
|
|
357
|
+
@click.option("--plan-rounds", default=2, show_default=True)
|
|
358
|
+
@click.option("--execute-rounds", default=1, show_default=True)
|
|
359
|
+
@click.option(
|
|
360
|
+
"--research-only",
|
|
361
|
+
is_flag=True,
|
|
362
|
+
help="Stop after research stage (for quick iteration / cost control)",
|
|
363
|
+
)
|
|
364
|
+
def drive(
|
|
365
|
+
topic: str,
|
|
366
|
+
alpha_cli: str,
|
|
367
|
+
alpha_model: str | None,
|
|
368
|
+
timeout: int,
|
|
369
|
+
research_rounds: int,
|
|
370
|
+
plan_rounds: int,
|
|
371
|
+
execute_rounds: int,
|
|
372
|
+
research_only: bool,
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Full loop: open focus, run research → plan → execute with peer review."""
|
|
375
|
+
console.print(f"[bold]paircode drive[/bold] topic=[cyan]{topic}[/cyan]")
|
|
376
|
+
console.print(
|
|
377
|
+
f" alpha = {alpha_cli} ({alpha_model or 'default model'}), timeout = {timeout}s"
|
|
378
|
+
)
|
|
379
|
+
if research_only:
|
|
380
|
+
console.print(
|
|
381
|
+
f" [dim]research-only mode, rounds = {research_rounds}[/dim]"
|
|
382
|
+
)
|
|
383
|
+
stage_results = drive_research(
|
|
384
|
+
topic=topic,
|
|
385
|
+
alpha_cli=alpha_cli,
|
|
386
|
+
alpha_model=alpha_model,
|
|
387
|
+
timeout_s=timeout,
|
|
388
|
+
rounds=research_rounds,
|
|
389
|
+
)
|
|
390
|
+
_render_stage_results("research", stage_results)
|
|
391
|
+
console.print(
|
|
392
|
+
f"\n[bold]Research stage complete.[/bold] "
|
|
393
|
+
f"Files at [cyan].paircode/{stage_results[0].focus_dir.name}/research/[/cyan]"
|
|
394
|
+
)
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
console.print(
|
|
398
|
+
f" [dim]research={research_rounds}r, plan={plan_rounds}r, "
|
|
399
|
+
f"execute={execute_rounds}r (this can take a while)[/dim]"
|
|
400
|
+
)
|
|
401
|
+
all_results = drive_full(
|
|
402
|
+
topic=topic,
|
|
403
|
+
alpha_cli=alpha_cli,
|
|
404
|
+
alpha_model=alpha_model,
|
|
405
|
+
timeout_s=timeout,
|
|
406
|
+
research_rounds=research_rounds,
|
|
407
|
+
plan_rounds=plan_rounds,
|
|
408
|
+
execute_rounds=execute_rounds,
|
|
409
|
+
)
|
|
410
|
+
for stage_name, stage_results in all_results.items():
|
|
411
|
+
_render_stage_results(stage_name, stage_results)
|
|
412
|
+
first_focus = next(iter(all_results.values()))[0].focus_dir
|
|
413
|
+
console.print(
|
|
414
|
+
f"\n[bold green]Drive complete.[/bold green] Focus: [cyan]{first_focus.name}[/cyan]"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
main()
|
paircode/detect.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Detect installed LLM CLIs and their config directories."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CliInfo:
|
|
11
|
+
name: str
|
|
12
|
+
binary: str
|
|
13
|
+
binary_path: Path | None
|
|
14
|
+
config_dir: Path
|
|
15
|
+
installed: bool
|
|
16
|
+
install_hint: str # what to tell the user if they want to install this CLI
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
KNOWN_CLIS: dict[str, tuple[str, Path, str]] = {
|
|
20
|
+
# name: (binary, config_dir, install_hint)
|
|
21
|
+
"claude": (
|
|
22
|
+
"claude",
|
|
23
|
+
Path.home() / ".claude",
|
|
24
|
+
"Install Claude Code: https://claude.com/product/claude-code",
|
|
25
|
+
),
|
|
26
|
+
"codex": (
|
|
27
|
+
"codex",
|
|
28
|
+
Path.home() / ".codex",
|
|
29
|
+
"Install Codex CLI: `npm i -g @openai/codex`",
|
|
30
|
+
),
|
|
31
|
+
"gemini": (
|
|
32
|
+
"gemini",
|
|
33
|
+
Path.home() / ".gemini",
|
|
34
|
+
"Install Gemini CLI: `npm i -g @google/gemini-cli`",
|
|
35
|
+
),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_all() -> dict[str, CliInfo]:
|
|
40
|
+
"""Return a dict of name -> CliInfo for every known LLM CLI we care about."""
|
|
41
|
+
result: dict[str, CliInfo] = {}
|
|
42
|
+
for name, (binary, config_dir, hint) in KNOWN_CLIS.items():
|
|
43
|
+
found = shutil.which(binary)
|
|
44
|
+
result[name] = CliInfo(
|
|
45
|
+
name=name,
|
|
46
|
+
binary=binary,
|
|
47
|
+
binary_path=Path(found) if found else None,
|
|
48
|
+
config_dir=config_dir,
|
|
49
|
+
installed=bool(found),
|
|
50
|
+
install_hint=hint,
|
|
51
|
+
)
|
|
52
|
+
return result
|