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/drive.py ADDED
@@ -0,0 +1,385 @@
1
+ """Drive loop: research → plan → execute stages with peer review rounds.
2
+
3
+ Each stage round consists of:
4
+ 1. Every peer (alpha + others) writes their v_N output in parallel (cold if N=1).
5
+ 2. Every peer reviews alpha's v_N and writes review-round-N-{peer}-critiques-alpha.md.
6
+ 3. Alpha reads all reviews and writes v_{N+1} (if more rounds requested).
7
+
8
+ Plan and execute stages follow the same pattern with different prompts.
9
+ Execute stage produces .md orchestration logs only (not actual code — the
10
+ full-fork peers' code still lives under .paircode/peers/peer-N-*/ and is
11
+ managed by the peer CLIs themselves, which may invoke their own tools).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import concurrent.futures
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+ from paircode.gates import check_convergence, check_human_gate
21
+ from paircode.runner import run_peer, PeerRunResult
22
+ from paircode.state import (
23
+ PaircodeState,
24
+ find_paircode,
25
+ init_paircode,
26
+ open_focus,
27
+ read_peers,
28
+ )
29
+
30
+
31
+ StageName = Literal["research", "plan", "execute"]
32
+
33
+
34
+ COLD_PROMPTS: dict[StageName, str] = {
35
+ "research": """You are participating in a paircode peer-review cycle.
36
+ The topic for this focus is:
37
+
38
+ {topic}
39
+
40
+ This is the RESEARCH stage, round 1 (cold). Produce independent research on this
41
+ topic. Write a detailed markdown response covering:
42
+
43
+ 1. Problem framing — what's actually being asked?
44
+ 2. Prior art — what exists that's similar or informative?
45
+ 3. Key questions that need to be answered before planning
46
+ 4. Constraints and assumptions you're making
47
+ 5. Initial directions worth exploring
48
+
49
+ Be honest, skeptical, specific. Your response is saved verbatim to disk as a
50
+ file-trace for other LLMs to read. Clean markdown, no preamble, just research.
51
+ """,
52
+ "plan": """You are participating in a paircode peer-review cycle.
53
+ The topic for this focus is:
54
+
55
+ {topic}
56
+
57
+ This is the PLAN stage, round 1 (cold). The research stage has already produced
58
+ outputs you can read at .paircode/{focus}/research/*-FINAL.md or *-v{last}.md.
59
+ Based on that research (and your own judgment), produce a concrete plan:
60
+
61
+ 1. Goal (one sentence)
62
+ 2. Scope (what's in, what's out)
63
+ 3. Steps — numbered, each actionable
64
+ 4. Risks and unknowns
65
+ 5. Success criteria
66
+
67
+ Be KISS. LLMs tend to overbake plans — don't. Ship small, iterate.
68
+ """,
69
+ "execute": """You are participating in a paircode peer-review cycle.
70
+ The topic for this focus is:
71
+
72
+ {topic}
73
+
74
+ This is the EXECUTE stage, round 1. The plan stage outputs are at
75
+ .paircode/{focus}/plan/*-FINAL.md. Execute the plan (write code, run commands,
76
+ whatever the plan prescribes). Produce a markdown summary of what you did:
77
+
78
+ 1. What you built / changed / ran
79
+ 2. Files touched (with paths)
80
+ 3. Tests / verification status
81
+ 4. What's left open or blocked
82
+
83
+ This summary becomes a file-trace. If your CLI has file-write and command-exec
84
+ tools, use them freely — just report what happened in your response.
85
+ """,
86
+ }
87
+
88
+
89
+ REVIEW_PROMPT_TEMPLATE = """You are a peer reviewer in a paircode cycle.
90
+
91
+ Read alpha's {stage} output at:
92
+
93
+ {alpha_file}
94
+
95
+ The topic for this focus is: {topic}
96
+
97
+ Produce an honest, skeptical review of alpha's work. Rank your findings by
98
+ severity (HIGH / MEDIUM / LOW). For each finding, cite alpha's output specifically
99
+ (line numbers or quoted phrases). Be generous with problems; be stingy with praise.
100
+
101
+ Your review will be saved to {review_path} so alpha can read it in the next
102
+ iteration round. Clean markdown. No preamble — just the ranked findings.
103
+ """
104
+
105
+
106
+ ALPHA_REVISION_PROMPT_TEMPLATE = """You are alpha in a paircode cycle, writing
107
+ version {version} of your {stage} output for the focus on:
108
+
109
+ {topic}
110
+
111
+ Your prior version is at:
112
+
113
+ {alpha_prior}
114
+
115
+ The peer reviews of your prior version are at:
116
+
117
+ {reviews_list}
118
+
119
+ Read your prior version AND all the reviews. Incorporate valid criticisms,
120
+ defend positions where critics are wrong, and produce version {version}. This
121
+ is NOT a complete rewrite unless the critics are catastrophically right —
122
+ revise incrementally.
123
+
124
+ Clean markdown. No meta-commentary — just the revised output.
125
+ """
126
+
127
+
128
+ @dataclass
129
+ class StageResult:
130
+ focus_dir: Path
131
+ stage: str
132
+ version: int
133
+ peer_results: list[PeerRunResult] = field(default_factory=list)
134
+ review_results: list[PeerRunResult] = field(default_factory=list)
135
+ alpha_revision: PeerRunResult | None = None
136
+
137
+ @property
138
+ def successes(self) -> int:
139
+ return sum(
140
+ 1
141
+ for r in self.peer_results + self.review_results + ([self.alpha_revision] if self.alpha_revision else [])
142
+ if r and r.ok
143
+ )
144
+
145
+ @property
146
+ def failures(self) -> int:
147
+ return sum(
148
+ 1
149
+ for r in self.peer_results + self.review_results + ([self.alpha_revision] if self.alpha_revision else [])
150
+ if r and not r.ok
151
+ )
152
+
153
+
154
+ def _ensure_state() -> PaircodeState:
155
+ state = find_paircode()
156
+ if state is None:
157
+ state = init_paircode()
158
+ return state
159
+
160
+
161
+ def _format_prompt(stage: StageName, topic: str, focus_dir: Path) -> str:
162
+ return COLD_PROMPTS[stage].format(
163
+ topic=topic,
164
+ focus=focus_dir.name,
165
+ last="1",
166
+ )
167
+
168
+
169
+ def run_stage_cold(
170
+ state: PaircodeState,
171
+ focus_dir: Path,
172
+ stage: StageName,
173
+ topic: str,
174
+ alpha_cli: str = "claude",
175
+ alpha_model: str | None = None,
176
+ timeout_s: int = 600,
177
+ ) -> StageResult:
178
+ """Run cold v1 of a stage — alpha + all peers in parallel."""
179
+ stage_dir = focus_dir / stage
180
+ stage_dir.mkdir(exist_ok=True)
181
+ (stage_dir / "reviews").mkdir(exist_ok=True)
182
+ prompt = _format_prompt(stage, topic, focus_dir)
183
+
184
+ peers = read_peers(state)
185
+ jobs: list[tuple[str, str, str | None, Path]] = []
186
+ jobs.append(("alpha", alpha_cli, alpha_model, stage_dir / "alpha-v1.md"))
187
+ for p in peers:
188
+ jobs.append((p["id"], p["cli"], p.get("model"), stage_dir / f"{p['id']}-v1.md"))
189
+
190
+ results: list[PeerRunResult] = []
191
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(jobs))) as ex:
192
+ futures = {
193
+ ex.submit(run_peer, pid, cli, prompt, path, model=model, timeout_s=timeout_s): pid
194
+ for (pid, cli, model, path) in jobs
195
+ }
196
+ for fut in concurrent.futures.as_completed(futures):
197
+ results.append(fut.result())
198
+
199
+ return StageResult(
200
+ focus_dir=focus_dir,
201
+ stage=stage,
202
+ version=1,
203
+ peer_results=results,
204
+ )
205
+
206
+
207
+ def run_review_round(
208
+ state: PaircodeState,
209
+ focus_dir: Path,
210
+ stage: StageName,
211
+ topic: str,
212
+ version: int,
213
+ timeout_s: int = 600,
214
+ ) -> list[PeerRunResult]:
215
+ """Every peer reviews alpha's v_{version}. Writes to reviews/round-{version}-*.md."""
216
+ stage_dir = focus_dir / stage
217
+ reviews_dir = stage_dir / "reviews"
218
+ reviews_dir.mkdir(exist_ok=True)
219
+ alpha_file = stage_dir / f"alpha-v{version}.md"
220
+ if not alpha_file.exists():
221
+ raise FileNotFoundError(f"alpha output missing: {alpha_file}")
222
+
223
+ peers = read_peers(state)
224
+ jobs: list[tuple[str, str, str | None, str, Path]] = []
225
+ for p in peers:
226
+ review_path = reviews_dir / f"round-{version:02d}-{p['id']}-critiques-alpha.md"
227
+ prompt = REVIEW_PROMPT_TEMPLATE.format(
228
+ stage=stage,
229
+ alpha_file=alpha_file,
230
+ topic=topic,
231
+ review_path=review_path,
232
+ )
233
+ jobs.append((p["id"], p["cli"], p.get("model"), prompt, review_path))
234
+
235
+ results: list[PeerRunResult] = []
236
+ if not jobs:
237
+ return results
238
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(jobs)) as ex:
239
+ futures = {
240
+ ex.submit(run_peer, pid, cli, prompt, path, model=model, timeout_s=timeout_s): pid
241
+ for (pid, cli, model, prompt, path) in jobs
242
+ }
243
+ for fut in concurrent.futures.as_completed(futures):
244
+ results.append(fut.result())
245
+ return results
246
+
247
+
248
+ def run_alpha_revision(
249
+ state: PaircodeState,
250
+ focus_dir: Path,
251
+ stage: StageName,
252
+ topic: str,
253
+ version: int,
254
+ alpha_cli: str = "claude",
255
+ alpha_model: str | None = None,
256
+ timeout_s: int = 600,
257
+ ) -> PeerRunResult:
258
+ """Alpha reads its own v_{version} and all peer reviews, writes v_{version+1}."""
259
+ stage_dir = focus_dir / stage
260
+ alpha_prior = stage_dir / f"alpha-v{version}.md"
261
+ reviews_dir = stage_dir / "reviews"
262
+ review_files = sorted(reviews_dir.glob(f"round-{version:02d}-*-critiques-alpha.md"))
263
+ reviews_list = "\n".join(f" - {p}" for p in review_files) or " (no reviews)"
264
+
265
+ prompt = ALPHA_REVISION_PROMPT_TEMPLATE.format(
266
+ version=version + 1,
267
+ stage=stage,
268
+ topic=topic,
269
+ alpha_prior=alpha_prior,
270
+ reviews_list=reviews_list,
271
+ )
272
+ out_path = stage_dir / f"alpha-v{version + 1}.md"
273
+ return run_peer(
274
+ peer_id="alpha",
275
+ cli=alpha_cli,
276
+ prompt=prompt,
277
+ output_path=out_path,
278
+ model=alpha_model,
279
+ timeout_s=timeout_s,
280
+ )
281
+
282
+
283
+ def run_stage(
284
+ topic: str,
285
+ focus_dir: Path,
286
+ stage: StageName,
287
+ rounds: int = 1,
288
+ alpha_cli: str = "claude",
289
+ alpha_model: str | None = None,
290
+ timeout_s: int = 600,
291
+ state: PaircodeState | None = None,
292
+ check_gates: bool = True,
293
+ ) -> list[StageResult]:
294
+ """Run a stage for `rounds` rounds. Round 1 is cold v1. Rounds 2+ do
295
+ review-then-revise cycles. Stops early on convergence or human-gate
296
+ sentinel unless check_gates=False. Returns one StageResult per round."""
297
+ if state is None:
298
+ state = _ensure_state()
299
+ if rounds < 1:
300
+ raise ValueError("rounds must be >= 1")
301
+
302
+ stage_dir = focus_dir / stage
303
+
304
+ # Check human gate BEFORE starting
305
+ if check_gates:
306
+ gate = check_human_gate(stage_dir)
307
+ if gate.stop:
308
+ # Still run cold v1 so we have something, then stop.
309
+ pass
310
+
311
+ # Round 1: cold v1
312
+ first = run_stage_cold(
313
+ state, focus_dir, stage, topic,
314
+ alpha_cli=alpha_cli, alpha_model=alpha_model, timeout_s=timeout_s,
315
+ )
316
+ out = [first]
317
+
318
+ # Rounds 2..N: review + revise, with gate checks between rounds
319
+ for r in range(2, rounds + 1):
320
+ if check_gates:
321
+ hg = check_human_gate(stage_dir)
322
+ if hg.stop:
323
+ break
324
+ prev_version = r - 1
325
+ reviews = run_review_round(
326
+ state, focus_dir, stage, topic, prev_version, timeout_s=timeout_s
327
+ )
328
+ revision = run_alpha_revision(
329
+ state, focus_dir, stage, topic, prev_version,
330
+ alpha_cli=alpha_cli, alpha_model=alpha_model, timeout_s=timeout_s,
331
+ )
332
+ out.append(StageResult(
333
+ focus_dir=focus_dir,
334
+ stage=stage,
335
+ version=r,
336
+ review_results=reviews,
337
+ alpha_revision=revision,
338
+ ))
339
+ # Convergence check AFTER revision lands
340
+ if check_gates:
341
+ conv = check_convergence(stage_dir, r)
342
+ if conv.stop:
343
+ break
344
+ return out
345
+
346
+
347
+ def drive_research(
348
+ topic: str,
349
+ focus_name: str | None = None,
350
+ alpha_cli: str = "claude",
351
+ alpha_model: str | None = None,
352
+ timeout_s: int = 600,
353
+ rounds: int = 1,
354
+ ) -> list[StageResult]:
355
+ """Open a focus, run research stage for `rounds` rounds."""
356
+ state = _ensure_state()
357
+ focus_dir = open_focus(state, focus_name or topic, prompt=topic)
358
+ return run_stage(
359
+ topic, focus_dir, "research",
360
+ rounds=rounds, alpha_cli=alpha_cli, alpha_model=alpha_model,
361
+ timeout_s=timeout_s, state=state,
362
+ )
363
+
364
+
365
+ def drive_full(
366
+ topic: str,
367
+ focus_name: str | None = None,
368
+ alpha_cli: str = "claude",
369
+ alpha_model: str | None = None,
370
+ timeout_s: int = 600,
371
+ research_rounds: int = 2,
372
+ plan_rounds: int = 2,
373
+ execute_rounds: int = 1,
374
+ ) -> dict[str, list[StageResult]]:
375
+ """Full loop: open focus, run research + plan + execute. Returns per-stage results."""
376
+ state = _ensure_state()
377
+ focus_dir = open_focus(state, focus_name or topic, prompt=topic)
378
+ results: dict[str, list[StageResult]] = {}
379
+ for stage, rounds in (("research", research_rounds), ("plan", plan_rounds), ("execute", execute_rounds)):
380
+ results[stage] = run_stage(
381
+ topic, focus_dir, stage, # type: ignore[arg-type]
382
+ rounds=rounds, alpha_cli=alpha_cli, alpha_model=alpha_model,
383
+ timeout_s=timeout_s, state=state,
384
+ )
385
+ return results
paircode/gates.py ADDED
@@ -0,0 +1,62 @@
1
+ """Gate detection — two kinds of signals that stop a stage loop early.
2
+
3
+ 1. Convergence: alpha's v_N is essentially the same as v_{N-1}. No more work to do.
4
+ 2. Human gate: captain dropped a `HUMAN-GATE-*.md` file into the stage dir.
5
+
6
+ Both return a GateSignal. run_stage checks after each review+revise round and
7
+ stops if either fires.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from difflib import SequenceMatcher
13
+ from pathlib import Path
14
+
15
+
16
+ # If alpha_v_{N} and alpha_v_{N-1} are this similar (by SequenceMatcher ratio),
17
+ # we declare convergence. 0.95 = 95% identical character-level — conservative
18
+ # enough to only stop when almost nothing changed.
19
+ DEFAULT_CONVERGENCE_THRESHOLD = 0.95
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class GateSignal:
24
+ stop: bool
25
+ reason: str # "converged" | "human_gate" | "max_rounds" | ""
26
+ detail: str = ""
27
+
28
+
29
+ def check_convergence(
30
+ stage_dir: Path, version: int, threshold: float = DEFAULT_CONVERGENCE_THRESHOLD
31
+ ) -> GateSignal:
32
+ """Compare alpha-v{version}.md to alpha-v{version-1}.md. If similarity
33
+ exceeds threshold, signal stop."""
34
+ if version < 2:
35
+ return GateSignal(stop=False, reason="")
36
+ curr = stage_dir / f"alpha-v{version}.md"
37
+ prev = stage_dir / f"alpha-v{version - 1}.md"
38
+ if not curr.exists() or not prev.exists():
39
+ return GateSignal(stop=False, reason="")
40
+ curr_text = curr.read_text(encoding="utf-8", errors="ignore")
41
+ prev_text = prev.read_text(encoding="utf-8", errors="ignore")
42
+ ratio = SequenceMatcher(None, prev_text, curr_text).ratio()
43
+ if ratio >= threshold:
44
+ return GateSignal(
45
+ stop=True,
46
+ reason="converged",
47
+ detail=f"alpha-v{version} ~= alpha-v{version-1} (similarity {ratio:.3f} >= {threshold})",
48
+ )
49
+ return GateSignal(stop=False, reason="", detail=f"similarity {ratio:.3f}")
50
+
51
+
52
+ def check_human_gate(stage_dir: Path) -> GateSignal:
53
+ """If any HUMAN-GATE-*.md file is in the stage dir, signal stop."""
54
+ gates = sorted(stage_dir.glob("HUMAN-GATE-*.md"))
55
+ if gates:
56
+ return GateSignal(
57
+ stop=True,
58
+ reason="human_gate",
59
+ detail=f"found {len(gates)} gate file(s): "
60
+ + ", ".join(g.name for g in gates),
61
+ )
62
+ return GateSignal(stop=False, reason="")
paircode/handshake.py ADDED
@@ -0,0 +1,55 @@
1
+ """Handshake: detect installed CLIs and propose a peer roster for peers.yaml.
2
+
3
+ alpha is always implicit (= the project itself), so handshake proposes only
4
+ the peers. The captain can edit peers.yaml before running the first stage.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import asdict, dataclass
9
+
10
+ from paircode.detect import detect_all
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ProposedPeer:
15
+ id: str
16
+ cli: str
17
+ mode: str
18
+ priority: str
19
+ notes: str = ""
20
+
21
+
22
+ # Default ranking of peers when auto-proposing a roster.
23
+ # Alpha is typically Claude (the primary LLM the captain is already using),
24
+ # so Claude is skipped here — peers are the other voices.
25
+ _PEER_RANK: list[tuple[str, str, str, str]] = [
26
+ # (cli, default_mode, priority, notes)
27
+ ("codex", "full-fork", "high", "Second opinion, good for silent-agreement hunts"),
28
+ ("ollama", "full-fork", "medium", "Local unlimited; good if a capable model is pulled"),
29
+ ("gemini", "opinion-only", "low", "Free tier — use for quick opinions, not full forks"),
30
+ ]
31
+
32
+
33
+ def propose_roster() -> list[ProposedPeer]:
34
+ """Scan installed CLIs, return a ranked list of peers to populate peers.yaml."""
35
+ detected = detect_all()
36
+ proposed: list[ProposedPeer] = []
37
+ peer_letter = ord("a")
38
+ for cli, default_mode, priority, notes in _PEER_RANK:
39
+ info = detected.get(cli)
40
+ if info and info.installed:
41
+ proposed.append(
42
+ ProposedPeer(
43
+ id=f"peer-{chr(peer_letter)}-{cli}",
44
+ cli=cli,
45
+ mode=default_mode,
46
+ priority=priority,
47
+ notes=notes,
48
+ )
49
+ )
50
+ peer_letter += 1
51
+ return proposed
52
+
53
+
54
+ def proposed_as_yaml_dicts(proposed: list[ProposedPeer]) -> list[dict]:
55
+ return [asdict(p) for p in proposed]
paircode/installer.py ADDED
@@ -0,0 +1,161 @@
1
+ """Install paircode's slash command / rules into the detected LLM CLIs.
2
+
3
+ Strategy per CLI:
4
+ - Claude Code: write `~/.claude/commands/paircode.md` (user-level global slash command).
5
+ - Codex: write `~/.codex/prompts/paircode.md` if codex supports it, otherwise write
6
+ a rules snippet at `~/.codex/rules/paircode.rules` that gets picked up as context.
7
+ - Gemini: no global slash-command primitive; install as a skill via `gemini skills install`
8
+ or write an extension manifest. Fallback: print instructions to invoke `paircode` from shell.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ from dataclasses import dataclass
14
+ from importlib import resources
15
+ from pathlib import Path
16
+
17
+ from paircode.detect import detect_all, CliInfo
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class InstallResult:
22
+ cli_name: str
23
+ action: str # "installed", "skipped", "failed"
24
+ path: Path | None
25
+ message: str
26
+
27
+
28
+ def _read_template(name: str) -> str:
29
+ return resources.files("paircode.templates").joinpath(name).read_text(encoding="utf-8")
30
+
31
+
32
+ def install_claude(info: CliInfo) -> InstallResult:
33
+ if not info.installed:
34
+ return InstallResult(
35
+ cli_name="claude",
36
+ action="skipped",
37
+ path=None,
38
+ message=f"claude CLI not on PATH. {info.install_hint}",
39
+ )
40
+ commands_dir = info.config_dir / "commands"
41
+ commands_dir.mkdir(parents=True, exist_ok=True)
42
+ target = commands_dir / "paircode.md"
43
+ template = _read_template("claude_slash_command.md")
44
+ target.write_text(template, encoding="utf-8")
45
+ return InstallResult(
46
+ cli_name="claude",
47
+ action="installed",
48
+ path=target,
49
+ message=f"Wrote /paircode slash command to {target}. Use it from any Claude Code session.",
50
+ )
51
+
52
+
53
+ def install_codex(info: CliInfo) -> InstallResult:
54
+ if not info.installed:
55
+ return InstallResult(
56
+ cli_name="codex",
57
+ action="skipped",
58
+ path=None,
59
+ message=f"codex CLI not on PATH. {info.install_hint}",
60
+ )
61
+ # Codex doesn't have a user-facing slash-command dir; we write a rules snippet
62
+ # that codex reads as context in every session. This is graceful fallback.
63
+ rules_dir = info.config_dir / "rules"
64
+ rules_dir.mkdir(parents=True, exist_ok=True)
65
+ target = rules_dir / "paircode.rules"
66
+ template = _read_template("codex_rules.md")
67
+ target.write_text(template, encoding="utf-8")
68
+ return InstallResult(
69
+ cli_name="codex",
70
+ action="installed",
71
+ path=target,
72
+ message=(
73
+ f"Wrote paircode rules to {target}. Codex will see paircode as an "
74
+ "available tool in its context. Invoke via `codex exec 'paircode ...'`."
75
+ ),
76
+ )
77
+
78
+
79
+ def install_gemini(info: CliInfo) -> InstallResult:
80
+ if not info.installed:
81
+ return InstallResult(
82
+ cli_name="gemini",
83
+ action="skipped",
84
+ path=None,
85
+ message=f"gemini CLI not on PATH. {info.install_hint}",
86
+ )
87
+ # Gemini CLI exposes `gemini skills install <source>`, but we don't want to
88
+ # depend on the gemini binary being on PATH at install time. Instead, we
89
+ # document the invocation pattern: gemini users call paircode from shell or
90
+ # via a skill installed manually. Write a reminder file at ~/.gemini/paircode.md.
91
+ info.config_dir.mkdir(parents=True, exist_ok=True)
92
+ target = info.config_dir / "paircode.md"
93
+ template = _read_template("codex_rules.md") # same "you have paircode CLI" content
94
+ target.write_text(template, encoding="utf-8")
95
+ return InstallResult(
96
+ cli_name="gemini",
97
+ action="installed",
98
+ path=target,
99
+ message=(
100
+ f"Wrote paircode reference to {target}. Gemini users: invoke via "
101
+ "`gemini -p 'run paircode status'` or from shell directly. Full "
102
+ "skill-registration support lands in a later release."
103
+ ),
104
+ )
105
+
106
+
107
+ def install_all() -> list[InstallResult]:
108
+ detected = detect_all()
109
+ installers = {
110
+ "claude": install_claude,
111
+ "codex": install_codex,
112
+ "gemini": install_gemini,
113
+ }
114
+ results: list[InstallResult] = []
115
+ for name, info in detected.items():
116
+ installer = installers.get(name)
117
+ if not installer:
118
+ continue
119
+ try:
120
+ results.append(installer(info))
121
+ except Exception as exc: # pragma: no cover — defensive
122
+ results.append(
123
+ InstallResult(
124
+ cli_name=name,
125
+ action="failed",
126
+ path=None,
127
+ message=f"{type(exc).__name__}: {exc}",
128
+ )
129
+ )
130
+ return results
131
+
132
+
133
+ def uninstall_all() -> list[InstallResult]:
134
+ """Remove paircode entries from every known CLI config dir (idempotent)."""
135
+ results: list[InstallResult] = []
136
+ paths = [
137
+ (Path.home() / ".claude" / "commands" / "paircode.md", "claude"),
138
+ (Path.home() / ".codex" / "rules" / "paircode.rules", "codex"),
139
+ (Path.home() / ".gemini" / "paircode.md", "gemini"),
140
+ ]
141
+ for path, cli_name in paths:
142
+ if path.exists():
143
+ path.unlink()
144
+ results.append(
145
+ InstallResult(
146
+ cli_name=cli_name,
147
+ action="installed", # we removed it
148
+ path=path,
149
+ message=f"Removed {path}",
150
+ )
151
+ )
152
+ else:
153
+ results.append(
154
+ InstallResult(
155
+ cli_name=cli_name,
156
+ action="skipped",
157
+ path=path,
158
+ message=f"Nothing to remove at {path}",
159
+ )
160
+ )
161
+ return results