paircode 0.8.1__tar.gz → 0.10.1__tar.gz

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.
Files changed (33) hide show
  1. {paircode-0.8.1 → paircode-0.10.1}/PKG-INFO +11 -8
  2. {paircode-0.8.1 → paircode-0.10.1}/README.md +9 -6
  3. {paircode-0.8.1 → paircode-0.10.1}/pyproject.toml +2 -2
  4. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/__init__.py +1 -1
  5. paircode-0.10.1/src/paircode/installer.py +322 -0
  6. paircode-0.10.1/src/paircode/templates/codex_slash_command.md +24 -0
  7. paircode-0.10.1/src/paircode/templates/gemini_slash_command.toml +25 -0
  8. {paircode-0.8.1 → paircode-0.10.1}/tests/test_cli_smoke.py +132 -7
  9. {paircode-0.8.1 → paircode-0.10.1}/tests/test_smoke.py +50 -21
  10. paircode-0.8.1/src/paircode/installer.py +0 -183
  11. {paircode-0.8.1 → paircode-0.10.1}/.gitignore +0 -0
  12. {paircode-0.8.1 → paircode-0.10.1}/LICENSE +0 -0
  13. {paircode-0.8.1 → paircode-0.10.1}/diary/001-step-a-architecture.md +0 -0
  14. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/__main__.py +0 -0
  15. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/cli.py +0 -0
  16. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/detect.py +0 -0
  17. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/drive.py +0 -0
  18. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/gates.py +0 -0
  19. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/handshake.py +0 -0
  20. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/journey.py +0 -0
  21. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/runner.py +0 -0
  22. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/seal.py +0 -0
  23. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/state.py +0 -0
  24. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/FOCUS.md +0 -0
  25. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/JOURNEY.md +0 -0
  26. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/claude_slash_command.md +0 -0
  27. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/codex_rules.md +0 -0
  28. {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/peers.yaml +0 -0
  29. {paircode-0.8.1 → paircode-0.10.1}/tests/__init__.py +0 -0
  30. {paircode-0.8.1 → paircode-0.10.1}/tests/test_drive.py +0 -0
  31. {paircode-0.8.1 → paircode-0.10.1}/tests/test_gates.py +0 -0
  32. {paircode-0.8.1 → paircode-0.10.1}/tests/test_seal.py +0 -0
  33. {paircode-0.8.1 → paircode-0.10.1}/tests/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paircode
3
- Version: 0.8.1
3
+ Version: 0.10.1
4
4
  Summary: Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review.
5
5
  Project-URL: Homepage, https://github.com/starshipagentic/paircode
6
6
  Project-URL: Repository, https://github.com/starshipagentic/paircode
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Software Development
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: click>=8.1
24
- Requires-Dist: cliworker>=0.7.1
24
+ Requires-Dist: cliworker>=0.8.1
25
25
  Requires-Dist: pyyaml>=6.0
26
26
  Requires-Dist: rich>=13.0
27
27
  Description-Content-Type: text/markdown
@@ -36,15 +36,18 @@ Description-Content-Type: text/markdown
36
36
 
37
37
  ```bash
38
38
  pipx install paircode # or: pip install --user paircode
39
- paircode install # registers /paircode in Claude Code (global slash command)
39
+ paircode install # registers /paircode in every detected LLM CLI
40
40
  ```
41
41
 
42
- After install, open Claude Code in any project and `/paircode` is available.
42
+ After install, `/paircode` is available in all three:
43
43
 
44
- > **Note**: codex and gemini don't have a slash-command registration system
45
- > we can target, so `paircode install` is a no-op for them. From codex or
46
- > gemini, invoke paircode from your shell: `paircode status`, `paircode drive "..."`
47
- > etc. (or wrapped: `codex exec 'paircode status'`).
44
+ | CLI | File installed |
45
+ |---|---|
46
+ | Claude Code | `~/.claude/commands/paircode.md` |
47
+ | Codex CLI | `~/.codex/prompts/paircode.md` |
48
+ | Gemini CLI | `~/.gemini/commands/paircode.toml` |
49
+
50
+ Open any of them and type `/paircode`. In Gemini, you may need `/commands reload` the first time.
48
51
 
49
52
  As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
50
53
  the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
@@ -8,15 +8,18 @@
8
8
 
9
9
  ```bash
10
10
  pipx install paircode # or: pip install --user paircode
11
- paircode install # registers /paircode in Claude Code (global slash command)
11
+ paircode install # registers /paircode in every detected LLM CLI
12
12
  ```
13
13
 
14
- After install, open Claude Code in any project and `/paircode` is available.
14
+ After install, `/paircode` is available in all three:
15
15
 
16
- > **Note**: codex and gemini don't have a slash-command registration system
17
- > we can target, so `paircode install` is a no-op for them. From codex or
18
- > gemini, invoke paircode from your shell: `paircode status`, `paircode drive "..."`
19
- > etc. (or wrapped: `codex exec 'paircode status'`).
16
+ | CLI | File installed |
17
+ |---|---|
18
+ | Claude Code | `~/.claude/commands/paircode.md` |
19
+ | Codex CLI | `~/.codex/prompts/paircode.md` |
20
+ | Gemini CLI | `~/.gemini/commands/paircode.toml` |
21
+
22
+ Open any of them and type `/paircode`. In Gemini, you may need `/commands reload` the first time.
20
23
 
21
24
  As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
22
25
  the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "paircode"
7
- version = "0.8.1"
7
+ version = "0.10.1"
8
8
  description = "Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -39,7 +39,7 @@ dependencies = [
39
39
  "click>=8.1",
40
40
  "pyyaml>=6.0",
41
41
  "rich>=13.0",
42
- "cliworker>=0.7.1",
42
+ "cliworker>=0.8.1",
43
43
  ]
44
44
 
45
45
  [project.urls]
@@ -2,4 +2,4 @@
2
2
 
3
3
  See README.md and diary/001-step-a-architecture.md for the full design.
4
4
  """
5
- __version__ = "0.8.0"
5
+ __version__ = "0.10.0"
@@ -0,0 +1,322 @@
1
+ """Install paircode's slash command into detected LLM CLIs.
2
+
3
+ Strategy per CLI:
4
+ - Claude Code: file-drop ~/.claude/commands/paircode.md
5
+ Claude's native plugin-install requires publishing a marketplace;
6
+ overkill for one command. File-drop is first-class + documented.
7
+ - Codex CLI: native register via `codex marketplace add starshipagentic/paircode-codex`
8
+ (git-clones the satellite repo into ~/.codex/.tmp/marketplaces/).
9
+ Satellite contains .agents/plugins/marketplace.json + plugin manifest +
10
+ commands/paircode.md. Replaces the v0.9 file-drop at ~/.codex/prompts/.
11
+ - Gemini CLI: native register via `gemini extensions install https://github.com/starshipagentic/paircode-gemini --consent`
12
+ Git-clones the satellite repo into ~/.gemini/extensions/paircode/.
13
+ Satellite contains gemini-extension.json + commands/paircode.toml.
14
+ Replaces the v0.9 file-drop at ~/.gemini/commands/.
15
+
16
+ Native-register subprocess calls go through `cliworker.invoke()` — no LLM semantics,
17
+ stdin=DEVNULL for fail-fast on unexpected prompts, capture stderr for fallback
18
+ messaging.
19
+
20
+ Also cleans up legacy files from pre-v0.10 paircode versions on every install.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from importlib import resources
26
+ from pathlib import Path
27
+
28
+ from cliworker import invoke
29
+
30
+ from paircode.detect import detect_all, CliInfo
31
+
32
+
33
+ # Satellite repo coordinates — update here when repos move.
34
+ CODEX_MARKETPLACE = "starshipagentic/paircode-codex"
35
+ GEMINI_EXTENSION_URL = "https://github.com/starshipagentic/paircode-gemini"
36
+
37
+ # Per-CLI timeouts for the native install commands (git clones + setup).
38
+ NATIVE_INSTALL_TIMEOUT_S = 90
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class InstallResult:
43
+ cli_name: str
44
+ action: str # "installed", "skipped", "failed", "noop", "already"
45
+ path: Path | None
46
+ message: str
47
+
48
+
49
+ def _read_template(name: str) -> str:
50
+ return resources.files("paircode.templates").joinpath(name).read_text(encoding="utf-8")
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Per-CLI installers
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def install_claude(info: CliInfo) -> InstallResult:
58
+ """Write ~/.claude/commands/paircode.md — file-drop (native for Claude Code)."""
59
+ if not info.installed:
60
+ return InstallResult(
61
+ cli_name="claude", action="skipped", path=None,
62
+ message=f"claude CLI not on PATH. {info.install_hint}",
63
+ )
64
+ commands_dir = info.config_dir / "commands"
65
+ commands_dir.mkdir(parents=True, exist_ok=True)
66
+ target = commands_dir / "paircode.md"
67
+ target.write_text(_read_template("claude_slash_command.md"), encoding="utf-8")
68
+ return InstallResult(
69
+ cli_name="claude", action="installed", path=target,
70
+ message=f"Wrote /paircode slash command to {target}.",
71
+ )
72
+
73
+
74
+ def _codex_already_installed() -> bool:
75
+ """Idempotency check: is the paircode marketplace already registered?"""
76
+ config = Path.home() / ".codex" / "config.toml"
77
+ if not config.exists():
78
+ return False
79
+ try:
80
+ text = config.read_text(encoding="utf-8", errors="ignore")
81
+ except OSError:
82
+ return False
83
+ return "[marketplaces.paircode]" in text or "paircode-codex" in text
84
+
85
+
86
+ def install_codex(info: CliInfo) -> InstallResult:
87
+ """Native register via `codex marketplace add starshipagentic/paircode-codex`."""
88
+ if not info.installed:
89
+ return InstallResult(
90
+ cli_name="codex", action="skipped", path=None,
91
+ message=f"codex CLI not on PATH. {info.install_hint}",
92
+ )
93
+
94
+ if _codex_already_installed():
95
+ return InstallResult(
96
+ cli_name="codex", action="already", path=None,
97
+ message="paircode already registered with codex. Skip.",
98
+ )
99
+
100
+ result = invoke(
101
+ "codex", "marketplace", "add", CODEX_MARKETPLACE,
102
+ timeout_s=NATIVE_INSTALL_TIMEOUT_S,
103
+ )
104
+ if result.ok:
105
+ return InstallResult(
106
+ cli_name="codex", action="installed", path=None,
107
+ message=f"Ran: codex marketplace add {CODEX_MARKETPLACE}",
108
+ )
109
+ return InstallResult(
110
+ cli_name="codex", action="failed", path=None,
111
+ message=(
112
+ f"codex marketplace add failed. Run yourself:\n"
113
+ f" codex marketplace add {CODEX_MARKETPLACE}\n"
114
+ f"stderr: {result.stderr.strip()[:200]}"
115
+ ),
116
+ )
117
+
118
+
119
+ def _gemini_already_installed() -> bool:
120
+ """Idempotency check: is the paircode extension already installed?"""
121
+ result = invoke("gemini", "extensions", "list", timeout_s=15)
122
+ if not result.ok:
123
+ return False
124
+ return "paircode-gemini" in result.stdout or "paircode (" in result.stdout
125
+
126
+
127
+ def install_gemini(info: CliInfo) -> InstallResult:
128
+ """Native register via `gemini extensions install <url> --consent`."""
129
+ if not info.installed:
130
+ return InstallResult(
131
+ cli_name="gemini", action="skipped", path=None,
132
+ message=f"gemini CLI not on PATH. {info.install_hint}",
133
+ )
134
+
135
+ if _gemini_already_installed():
136
+ return InstallResult(
137
+ cli_name="gemini", action="already", path=None,
138
+ message="paircode already installed as gemini extension. Skip.",
139
+ )
140
+
141
+ result = invoke(
142
+ "gemini", "extensions", "install", GEMINI_EXTENSION_URL, "--consent",
143
+ timeout_s=NATIVE_INSTALL_TIMEOUT_S,
144
+ )
145
+ if result.ok:
146
+ return InstallResult(
147
+ cli_name="gemini", action="installed", path=None,
148
+ message=(
149
+ f"Ran: gemini extensions install {GEMINI_EXTENSION_URL} --consent. "
150
+ "Inside gemini, run `/commands reload` (or restart) to pick up /paircode."
151
+ ),
152
+ )
153
+ return InstallResult(
154
+ cli_name="gemini", action="failed", path=None,
155
+ message=(
156
+ f"gemini extensions install failed. Run yourself:\n"
157
+ f" gemini extensions install {GEMINI_EXTENSION_URL} --consent\n"
158
+ f"stderr: {result.stderr.strip()[:200]}"
159
+ ),
160
+ )
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Public entry points
165
+ # ---------------------------------------------------------------------------
166
+
167
+ def install_all() -> list[InstallResult]:
168
+ """Run every CLI's installer. Also cleans up legacy paths first."""
169
+ _clean_legacy_paths()
170
+ detected = detect_all()
171
+ installers = {
172
+ "claude": install_claude,
173
+ "codex": install_codex,
174
+ "gemini": install_gemini,
175
+ }
176
+ results: list[InstallResult] = []
177
+ for name, info in detected.items():
178
+ installer = installers.get(name)
179
+ if not installer:
180
+ continue
181
+ try:
182
+ results.append(installer(info))
183
+ except Exception as exc: # pragma: no cover — defensive
184
+ results.append(InstallResult(
185
+ cli_name=name, action="failed", path=None,
186
+ message=f"{type(exc).__name__}: {exc}",
187
+ ))
188
+ return results
189
+
190
+
191
+ def _clean_legacy_paths() -> None:
192
+ """Remove files that prior paircode versions installed and which we now
193
+ know to be broken, misplaced, or replaced by native-register flows."""
194
+ legacy_files = (
195
+ # v0.1–v0.7: wrote markdown to codex's Starlark rules dir → broke codex.
196
+ Path.home() / ".codex" / "rules" / "paircode.rules",
197
+ # v0.8: wrote a reference file at ~/.gemini/paircode.md (harmless but obsolete).
198
+ Path.home() / ".gemini" / "paircode.md",
199
+ # v0.9: wrote file-drop slash commands at these paths. v0.10+ uses native register.
200
+ Path.home() / ".codex" / "prompts" / "paircode.md",
201
+ Path.home() / ".gemini" / "commands" / "paircode.toml",
202
+ )
203
+ for path in legacy_files:
204
+ try:
205
+ if path.exists() and path.is_file():
206
+ path.unlink()
207
+ except OSError:
208
+ pass
209
+
210
+
211
+ def uninstall_all() -> list[InstallResult]:
212
+ """Remove paircode from every CLI. Uses native uninstall where available,
213
+ file-drop removal where not."""
214
+ results: list[InstallResult] = []
215
+
216
+ # Claude — file-drop, just remove the file.
217
+ claude_cmd = Path.home() / ".claude" / "commands" / "paircode.md"
218
+ if claude_cmd.exists():
219
+ try:
220
+ claude_cmd.unlink()
221
+ results.append(InstallResult(
222
+ cli_name="claude", action="installed",
223
+ path=claude_cmd, message=f"Removed {claude_cmd}",
224
+ ))
225
+ except OSError as exc:
226
+ results.append(InstallResult(
227
+ cli_name="claude", action="failed",
228
+ path=claude_cmd, message=f"Failed to remove {claude_cmd}: {exc}",
229
+ ))
230
+ else:
231
+ results.append(InstallResult(
232
+ cli_name="claude", action="skipped",
233
+ path=claude_cmd, message=f"Nothing to remove at {claude_cmd}",
234
+ ))
235
+
236
+ # Codex — no native remove command. Strip the marketplace stanza from config.toml
237
+ # and rm -rf the cache dir. Fragile but the only option until codex adds a CLI.
238
+ config = Path.home() / ".codex" / "config.toml"
239
+ cache_dir = Path.home() / ".codex" / ".tmp" / "marketplaces" / "paircode"
240
+ removed_codex = False
241
+ if config.exists():
242
+ try:
243
+ text = config.read_text(encoding="utf-8")
244
+ # Strip the [marketplaces.paircode] section and its key/value lines.
245
+ new_text = _strip_toml_section(text, "marketplaces.paircode")
246
+ new_text = _strip_toml_section(new_text, 'plugins."paircode@paircode"')
247
+ if new_text != text:
248
+ config.write_text(new_text, encoding="utf-8")
249
+ removed_codex = True
250
+ except OSError as exc:
251
+ results.append(InstallResult(
252
+ cli_name="codex", action="failed", path=config,
253
+ message=f"Failed to edit {config}: {exc}",
254
+ ))
255
+ if cache_dir.exists():
256
+ import shutil as _sh
257
+ try:
258
+ _sh.rmtree(cache_dir)
259
+ removed_codex = True
260
+ except OSError:
261
+ pass
262
+ if removed_codex:
263
+ results.append(InstallResult(
264
+ cli_name="codex", action="installed", path=None,
265
+ message="Removed paircode marketplace from ~/.codex/config.toml + cache.",
266
+ ))
267
+ else:
268
+ results.append(InstallResult(
269
+ cli_name="codex", action="skipped", path=None,
270
+ message="No paircode marketplace registered with codex.",
271
+ ))
272
+
273
+ # Gemini — native uninstall command exists.
274
+ result = invoke("gemini", "extensions", "uninstall", "paircode", timeout_s=30)
275
+ if result.ok:
276
+ results.append(InstallResult(
277
+ cli_name="gemini", action="installed", path=None,
278
+ message="Ran: gemini extensions uninstall paircode",
279
+ ))
280
+ else:
281
+ # May fail if not installed; treat as skipped when stderr mentions that
282
+ if "not installed" in result.stderr.lower() or "no extension" in result.stderr.lower():
283
+ results.append(InstallResult(
284
+ cli_name="gemini", action="skipped", path=None,
285
+ message="paircode gemini extension not installed; nothing to remove.",
286
+ ))
287
+ else:
288
+ results.append(InstallResult(
289
+ cli_name="gemini", action="failed", path=None,
290
+ message=(
291
+ f"gemini extensions uninstall failed. Run yourself:\n"
292
+ f" gemini extensions uninstall paircode\n"
293
+ f"stderr: {result.stderr.strip()[:200]}"
294
+ ),
295
+ ))
296
+
297
+ # Legacy cleanup (same as install_all)
298
+ _clean_legacy_paths()
299
+
300
+ return results
301
+
302
+
303
+ def _strip_toml_section(text: str, section_name: str) -> str:
304
+ """Remove `[section_name]` and its following key/value lines from a TOML string.
305
+
306
+ Returns the modified text. A section ends at the next `[...]` header or EOF.
307
+ No-op if the section isn't present.
308
+ """
309
+ lines = text.splitlines(keepends=True)
310
+ out: list[str] = []
311
+ skipping = False
312
+ target = f"[{section_name}]"
313
+ for line in lines:
314
+ stripped = line.strip()
315
+ if stripped == target:
316
+ skipping = True
317
+ continue
318
+ if skipping and stripped.startswith("[") and stripped.endswith("]"):
319
+ skipping = False # new section started
320
+ if not skipping:
321
+ out.append(line)
322
+ return "".join(out)
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: Run paircode — adversarial journey framework for LLM peer review. Bootstraps .paircode/ if missing, otherwise advances the active focus.
3
+ argument-hint: [SUBCOMMAND or TOPIC]
4
+ ---
5
+
6
+ You have access to the `paircode` CLI. It's a Python tool that orchestrates multi-LLM peer review (research → plan → execute) with file-traces on disk.
7
+
8
+ Behavior:
9
+
10
+ 1. **Parse the arguments.** Everything after `/paircode` is passed as CLI args. If empty, run `paircode` with no args (it prints status or bootstraps).
11
+ 2. **Run the CLI.** Invoke `paircode <args>` via shell. Capture stdout and stderr.
12
+ 3. **Relay output to the user.** Show what paircode printed.
13
+ 4. **If `paircode` spawns its own LLM subprocesses** (for research/plan/execute stages), it handles that internally — just relay status updates.
14
+ 5. **If the user's intent was a topic-level drive** (e.g., `/paircode "build a widget thing"`), invoke `paircode drive "<topic>"`.
15
+
16
+ Common subcommands:
17
+ - `paircode install` — register /paircode in Claude/Codex/Gemini
18
+ - `paircode status` — summarize .paircode/ state in cwd
19
+ - `paircode handshake` — detect installed LLM CLIs, propose peer roster
20
+ - `paircode focus <name>` — open a new focus
21
+ - `paircode stage {research|plan|execute}` — run one peer-review round
22
+ - `paircode drive "<topic>"` — high-level: open focus, run research → plan → execute
23
+
24
+ Always show the user what happened. Brief, honest, actionable.
@@ -0,0 +1,25 @@
1
+ description = "Run paircode — adversarial journey framework for LLM peer review. Bootstraps .paircode/ if missing, otherwise advances the active focus."
2
+
3
+ prompt = """
4
+ You have access to the `paircode` CLI — a Python tool that orchestrates multi-LLM peer review (research → plan → execute) with file-traces on disk.
5
+
6
+ Arguments: {{args}}
7
+
8
+ Behavior:
9
+
10
+ 1. Parse the arguments above. If empty, run `paircode` with no args (it prints status or bootstraps).
11
+ 2. Run the CLI: invoke `paircode {{args}}` via shell. Capture stdout and stderr.
12
+ 3. Relay output to the user. Show what paircode printed.
13
+ 4. If `paircode` spawns its own LLM subprocesses (for research/plan/execute stages), it handles that internally — just relay status updates.
14
+ 5. If the user's intent was a topic-level drive (e.g. `/paircode "build a widget thing"`), invoke `paircode drive "<topic>"`.
15
+
16
+ Common subcommands:
17
+ paircode install register /paircode in Claude/Codex/Gemini
18
+ paircode status summarize .paircode/ state in cwd
19
+ paircode handshake detect installed LLM CLIs, propose peer roster
20
+ paircode focus <name> open a new focus
21
+ paircode stage {research|plan|execute} run one peer-review round
22
+ paircode drive "<topic>" high-level: run research → plan → execute
23
+
24
+ Always show the user what happened. Brief, honest, actionable.
25
+ """
@@ -191,12 +191,29 @@ def test_run_peer_fast_kwarg_passes_through(monkeypatch, tmp_path):
191
191
 
192
192
 
193
193
  # ---------------------------------------------------------------------------
194
- # Installer: codex no-op + legacy cleanup
194
+ # Installer: native-register for codex + gemini via cliworker.invoke()
195
195
  # ---------------------------------------------------------------------------
196
196
 
197
- def test_installer_returns_noop_for_codex_not_installed_file(tmp_path, monkeypatch):
198
- """Regression of the 'broken codex rules' bug: installer must never write
199
- ~/.codex/rules/paircode.rules that file breaks codex's rule loader."""
197
+ def _fake_cliworker_invoke_success(captured):
198
+ """Return a fake invoke that captures calls and always succeeds."""
199
+ from cliworker.core import CLIResult
200
+ from cliworker.registry import CLISpec
201
+
202
+ def fake(cli, *args, **kwargs):
203
+ captured.append((cli, args))
204
+ stdout = ""
205
+ # Simulate empty list output for gemini extensions list (idempotency check)
206
+ return CLIResult(
207
+ spec=CLISpec(cli=cli), ok=True, stdout=stdout, stderr="",
208
+ duration_s=0.01, returncode=0, argv=[cli, *args], skipped_reason=None,
209
+ )
210
+
211
+ return fake
212
+
213
+
214
+ def test_installer_codex_calls_marketplace_add_via_invoke(tmp_path, monkeypatch):
215
+ """paircode v0.10 delegates codex install to cliworker.invoke()
216
+ running `codex marketplace add starshipagentic/paircode-codex`."""
200
217
  fake_home = tmp_path / "home"
201
218
  fake_home.mkdir()
202
219
  monkeypatch.setenv("HOME", str(fake_home))
@@ -209,15 +226,123 @@ def test_installer_returns_noop_for_codex_not_installed_file(tmp_path, monkeypat
209
226
  importlib.reload(inst)
210
227
 
211
228
  monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
212
- results = inst.install_all()
229
+ captured: list[tuple] = []
230
+ monkeypatch.setattr("paircode.installer.invoke", _fake_cliworker_invoke_success(captured))
213
231
 
232
+ results = inst.install_all()
214
233
  actions = {r.cli_name: r.action for r in results}
215
- assert actions["codex"] == "noop"
234
+ assert actions["codex"] == "installed"
216
235
 
217
- # File must NOT exist
236
+ codex_calls = [c for c in captured if c[0] == "codex"]
237
+ assert any(
238
+ c[1] == ("marketplace", "add", "starshipagentic/paircode-codex")
239
+ for c in codex_calls
240
+ ), f"expected `codex marketplace add starshipagentic/paircode-codex`, got {codex_calls}"
241
+
242
+ # Never writes the broken legacy rules file
218
243
  assert not (fake_home / ".codex" / "rules" / "paircode.rules").exists()
219
244
 
220
245
 
246
+ def test_installer_gemini_calls_extensions_install_via_invoke(tmp_path, monkeypatch):
247
+ """paircode v0.10 runs `gemini extensions install <url> --consent`."""
248
+ fake_home = tmp_path / "home"
249
+ fake_home.mkdir()
250
+ monkeypatch.setenv("HOME", str(fake_home))
251
+ import importlib
252
+
253
+ import paircode.detect as d
254
+ import paircode.installer as inst
255
+
256
+ importlib.reload(d)
257
+ importlib.reload(inst)
258
+
259
+ monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
260
+ captured: list[tuple] = []
261
+ monkeypatch.setattr("paircode.installer.invoke", _fake_cliworker_invoke_success(captured))
262
+
263
+ results = inst.install_all()
264
+ actions = {r.cli_name: r.action for r in results}
265
+ assert actions["gemini"] == "installed"
266
+
267
+ gemini_installs = [
268
+ c for c in captured
269
+ if c[0] == "gemini" and c[1][0:2] == ("extensions", "install") and "--consent" in c[1]
270
+ ]
271
+ assert gemini_installs, f"expected gemini extensions install --consent; got {captured}"
272
+
273
+
274
+ def test_installer_codex_idempotent_when_already_registered(tmp_path, monkeypatch):
275
+ """If the codex marketplace is already registered, install returns 'already'
276
+ without re-invoking `codex marketplace add`."""
277
+ fake_home = tmp_path / "home"
278
+ fake_home.mkdir()
279
+ (fake_home / ".codex").mkdir()
280
+ (fake_home / ".codex" / "config.toml").write_text(
281
+ '[marketplaces.paircode]\nsource = "https://github.com/starshipagentic/paircode-codex.git"\n'
282
+ )
283
+ monkeypatch.setenv("HOME", str(fake_home))
284
+ import importlib
285
+
286
+ import paircode.detect as d
287
+ import paircode.installer as inst
288
+
289
+ importlib.reload(d)
290
+ importlib.reload(inst)
291
+
292
+ monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
293
+ captured: list[tuple] = []
294
+ monkeypatch.setattr("paircode.installer.invoke", _fake_cliworker_invoke_success(captured))
295
+
296
+ results = inst.install_all()
297
+ codex_result = next(r for r in results if r.cli_name == "codex")
298
+ assert codex_result.action == "already", (
299
+ f"expected action='already'; got {codex_result.action!r} — {codex_result.message}"
300
+ )
301
+
302
+ # Marketplace-add must NOT have been invoked on codex
303
+ codex_add_calls = [c for c in captured if c[0] == "codex" and "marketplace" in c[1]]
304
+ assert not codex_add_calls, f"should not re-invoke marketplace add; got {codex_add_calls}"
305
+
306
+
307
+ def test_installer_fails_gracefully_with_actionable_message(tmp_path, monkeypatch):
308
+ """When invoke() returns .ok=False, installer surfaces the command to run
309
+ manually (copy-paste friendly)."""
310
+ fake_home = tmp_path / "home"
311
+ fake_home.mkdir()
312
+ monkeypatch.setenv("HOME", str(fake_home))
313
+ import importlib
314
+
315
+ import paircode.detect as d
316
+ import paircode.installer as inst
317
+
318
+ importlib.reload(d)
319
+ importlib.reload(inst)
320
+
321
+ monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
322
+
323
+ def failing_invoke(cli, *args, **kwargs):
324
+ from cliworker.core import CLIResult
325
+ from cliworker.registry import CLISpec
326
+
327
+ return CLIResult(
328
+ spec=CLISpec(cli=cli), ok=False, stdout="", stderr="simulated failure",
329
+ duration_s=0.01, returncode=1, argv=[cli, *args], skipped_reason=None,
330
+ )
331
+
332
+ monkeypatch.setattr("paircode.installer.invoke", failing_invoke)
333
+
334
+ results = inst.install_all()
335
+
336
+ codex_r = next(r for r in results if r.cli_name == "codex")
337
+ assert codex_r.action == "failed"
338
+ assert "codex marketplace add starshipagentic/paircode-codex" in codex_r.message
339
+
340
+ gemini_r = next(r for r in results if r.cli_name == "gemini")
341
+ assert gemini_r.action == "failed"
342
+ assert "gemini extensions install" in gemini_r.message
343
+ assert "--consent" in gemini_r.message
344
+
345
+
221
346
  # ---------------------------------------------------------------------------
222
347
  # Init + status dispatch smoke
223
348
  # ---------------------------------------------------------------------------
@@ -54,12 +54,13 @@ def test_handshake_shows_table(tmp_path, monkeypatch):
54
54
  assert name in result.output
55
55
 
56
56
 
57
- def test_install_writes_to_tmp_claude(tmp_path, monkeypatch):
58
- """Redirect ~/.claude to a tmp dir and verify install writes the slash command."""
57
+ def test_install_writes_claude_and_invokes_native_register(tmp_path, monkeypatch):
58
+ """v0.10: claude is file-drop, codex + gemini use native-register via
59
+ cliworker.invoke(). Verify the right file lands AND the right subprocess
60
+ commands get called."""
59
61
  fake_home = tmp_path / "home"
60
62
  fake_home.mkdir()
61
63
  monkeypatch.setenv("HOME", str(fake_home))
62
- # Re-import so Path.home() resolves to the new HOME
63
64
  import importlib
64
65
 
65
66
  import paircode.detect as d
@@ -68,34 +69,62 @@ def test_install_writes_to_tmp_claude(tmp_path, monkeypatch):
68
69
  importlib.reload(d)
69
70
  importlib.reload(inst)
70
71
 
71
- # Force all three CLIs "installed" by monkeypatching shutil.which
72
72
  def fake_which(binary):
73
73
  return f"/fake/bin/{binary}"
74
74
 
75
75
  monkeypatch.setattr("shutil.which", fake_which)
76
76
 
77
+ # Capture invoke() calls — codex + gemini installs go through it
78
+ invoked: list[tuple] = []
79
+
80
+ def fake_invoke(cli, *args, **kwargs):
81
+ from cliworker.core import CLIResult
82
+ from cliworker.registry import CLISpec
83
+
84
+ invoked.append((cli, args))
85
+ # For idempotency check calls (gemini extensions list): return empty stdout
86
+ # so it's "not installed" and the real install runs.
87
+ if args and args[0] == "extensions" and "list" in args:
88
+ return CLIResult(
89
+ spec=CLISpec(cli=cli), ok=True, stdout="", stderr="",
90
+ duration_s=0.01, returncode=0, argv=[cli, *args], skipped_reason=None,
91
+ )
92
+ # For actual install calls, succeed.
93
+ return CLIResult(
94
+ spec=CLISpec(cli=cli), ok=True, stdout="installed", stderr="",
95
+ duration_s=0.1, returncode=0, argv=[cli, *args], skipped_reason=None,
96
+ )
97
+
98
+ monkeypatch.setattr("paircode.installer.invoke", fake_invoke)
99
+
77
100
  results = inst.install_all()
78
101
  actions = {r.cli_name: r.action for r in results}
79
- # claude gets a real install (writes slash command file)
102
+
80
103
  assert actions["claude"] == "installed"
81
- # codex and gemini are intentional no-ops as of v0.8 — we stopped writing
82
- # broken files to ~/.codex/rules/. Both should report "noop" not "installed".
83
- assert actions["codex"] == "noop", (
84
- f"codex should be noop (not installed), got {actions['codex']!r}"
85
- )
86
- assert actions["gemini"] == "noop", (
87
- f"gemini should be noop (not installed), got {actions['gemini']!r}"
88
- )
89
- # Verify claude slash command actually landed on disk
104
+ assert actions["codex"] == "installed"
105
+ assert actions["gemini"] == "installed"
106
+
107
+ # Claude: file-drop landed on disk
90
108
  claude_cmd = fake_home / ".claude" / "commands" / "paircode.md"
91
109
  assert claude_cmd.exists()
92
- content = claude_cmd.read_text()
93
- assert "paircode" in content
94
- # And verify we do NOT write a broken codex rules file
95
- codex_rules = fake_home / ".codex" / "rules" / "paircode.rules"
96
- assert not codex_rules.exists(), (
97
- f"paircode should not write {codex_rules} — that file breaks codex's rule loader"
98
- )
110
+ assert "paircode" in claude_cmd.read_text()
111
+
112
+ # Codex: the invoke() call ran `codex marketplace add starshipagentic/paircode-codex`
113
+ codex_calls = [c for c in invoked if c[0] == "codex"]
114
+ assert any(
115
+ c[1] == ("marketplace", "add", "starshipagentic/paircode-codex")
116
+ for c in codex_calls
117
+ ), f"Expected codex marketplace add call; got {codex_calls}"
118
+
119
+ # Gemini: the invoke() call ran `gemini extensions install <url> --consent`
120
+ gemini_install = [
121
+ c for c in invoked
122
+ if c[0] == "gemini" and "install" in c[1] and "--consent" in c[1]
123
+ ]
124
+ assert gemini_install, f"Expected gemini extensions install --consent; got {invoked}"
125
+
126
+ # We do NOT write the legacy broken codex rules file
127
+ assert not (fake_home / ".codex" / "rules" / "paircode.rules").exists()
99
128
 
100
129
 
101
130
  def test_install_cleans_legacy_codex_rules_file(tmp_path, monkeypatch):
@@ -1,183 +0,0 @@
1
- """Install paircode's slash command into detected LLM CLIs.
2
-
3
- Strategy per CLI:
4
- - Claude Code: writes ~/.claude/commands/paircode.md as a real slash command.
5
- When the user types `/paircode` in any Claude Code session, this file
6
- tells Claude to invoke the paircode CLI via bash.
7
- - Codex: no file written. Codex doesn't have a user-facing slash-command
8
- system. Users invoke `paircode` from their shell or `codex exec paircode ...`.
9
- Previous versions wrote `~/.codex/rules/paircode.rules` — that file is
10
- Starlark-formatted and our markdown content broke codex's rule loader.
11
- `uninstall` still cleans up that legacy path.
12
- - Gemini: no file written. Same reasoning — gemini's custom-command story
13
- is `gemini skills install` with a git-repo source, not a markdown drop.
14
- That's a v0.9+ stretch goal. For now, gemini users invoke paircode from
15
- the shell.
16
- """
17
- from __future__ import annotations
18
-
19
- from dataclasses import dataclass
20
- from importlib import resources
21
- from pathlib import Path
22
-
23
- from paircode.detect import detect_all, CliInfo
24
-
25
-
26
- @dataclass(frozen=True)
27
- class InstallResult:
28
- cli_name: str
29
- action: str # "installed", "skipped", "failed", "noop"
30
- path: Path | None
31
- message: str
32
-
33
-
34
- def _read_template(name: str) -> str:
35
- return resources.files("paircode.templates").joinpath(name).read_text(encoding="utf-8")
36
-
37
-
38
- # ---------------------------------------------------------------------------
39
- # Per-CLI installers
40
- # ---------------------------------------------------------------------------
41
-
42
- def install_claude(info: CliInfo) -> InstallResult:
43
- """Write ~/.claude/commands/paircode.md — a real Claude Code slash command."""
44
- if not info.installed:
45
- return InstallResult(
46
- cli_name="claude",
47
- action="skipped",
48
- path=None,
49
- message=f"claude CLI not on PATH. {info.install_hint}",
50
- )
51
- commands_dir = info.config_dir / "commands"
52
- commands_dir.mkdir(parents=True, exist_ok=True)
53
- target = commands_dir / "paircode.md"
54
- template = _read_template("claude_slash_command.md")
55
- target.write_text(template, encoding="utf-8")
56
- return InstallResult(
57
- cli_name="claude",
58
- action="installed",
59
- path=target,
60
- message=f"Wrote /paircode slash command to {target}. Use it from any Claude Code session.",
61
- )
62
-
63
-
64
- def install_codex(info: CliInfo) -> InstallResult:
65
- """Codex has no user-facing slash-command system. No-op, print guidance."""
66
- if not info.installed:
67
- return InstallResult(
68
- cli_name="codex",
69
- action="skipped",
70
- path=None,
71
- message=f"codex CLI not on PATH. {info.install_hint}",
72
- )
73
- return InstallResult(
74
- cli_name="codex",
75
- action="noop",
76
- path=None,
77
- message=(
78
- "codex has no slash-command system — nothing to install. "
79
- "Invoke paircode from your shell (`paircode status`) or via "
80
- "`codex exec 'paircode status'` from inside a codex session."
81
- ),
82
- )
83
-
84
-
85
- def install_gemini(info: CliInfo) -> InstallResult:
86
- """Gemini's custom-command story uses `gemini skills install` with a git
87
- repo. That's not an easy installer drop — deferred to v0.9+. No-op."""
88
- if not info.installed:
89
- return InstallResult(
90
- cli_name="gemini",
91
- action="skipped",
92
- path=None,
93
- message=f"gemini CLI not on PATH. {info.install_hint}",
94
- )
95
- return InstallResult(
96
- cli_name="gemini",
97
- action="noop",
98
- path=None,
99
- message=(
100
- "gemini uses `gemini skills install` for custom commands — "
101
- "proper skill-registration lands in v0.9+. For now, invoke "
102
- "paircode from your shell or via `gemini -p 'run paircode status'`."
103
- ),
104
- )
105
-
106
-
107
- # ---------------------------------------------------------------------------
108
- # Public entry points
109
- # ---------------------------------------------------------------------------
110
-
111
- def install_all() -> list[InstallResult]:
112
- """Run every CLI's installer. Also cleans up legacy broken files first."""
113
- _clean_legacy_broken_files()
114
- detected = detect_all()
115
- installers = {
116
- "claude": install_claude,
117
- "codex": install_codex,
118
- "gemini": install_gemini,
119
- }
120
- results: list[InstallResult] = []
121
- for name, info in detected.items():
122
- installer = installers.get(name)
123
- if not installer:
124
- continue
125
- try:
126
- results.append(installer(info))
127
- except Exception as exc: # pragma: no cover — defensive
128
- results.append(
129
- InstallResult(
130
- cli_name=name, action="failed", path=None,
131
- message=f"{type(exc).__name__}: {exc}",
132
- )
133
- )
134
- return results
135
-
136
-
137
- def _clean_legacy_broken_files() -> None:
138
- """Remove files that previous versions of paircode installed and which we
139
- now know to be broken or misplaced. Silent; best-effort."""
140
- for legacy_path in (
141
- # v0.1–v0.7 wrote markdown to codex's Starlark rules dir, which broke
142
- # codex's rule loader. Always clean this up on any install.
143
- Path.home() / ".codex" / "rules" / "paircode.rules",
144
- # v0.1–v0.7 also dropped a reference file at ~/.gemini/paircode.md.
145
- # Harmless but no longer created; remove to keep the dir clean.
146
- Path.home() / ".gemini" / "paircode.md",
147
- ):
148
- try:
149
- if legacy_path.exists():
150
- legacy_path.unlink()
151
- except OSError:
152
- pass
153
-
154
-
155
- def uninstall_all() -> list[InstallResult]:
156
- """Remove paircode entries from every CLI config dir (idempotent).
157
- Also cleans up legacy broken paths from prior paircode versions."""
158
- results: list[InstallResult] = []
159
- paths: list[tuple[Path, str]] = [
160
- (Path.home() / ".claude" / "commands" / "paircode.md", "claude"),
161
- # Legacy paths — older paircode put these here, we still clean them up
162
- (Path.home() / ".codex" / "rules" / "paircode.rules", "codex"),
163
- (Path.home() / ".gemini" / "paircode.md", "gemini"),
164
- ]
165
- for path, cli_name in paths:
166
- if path.exists():
167
- try:
168
- path.unlink()
169
- results.append(InstallResult(
170
- cli_name=cli_name, action="installed", # "removed"
171
- path=path, message=f"Removed {path}",
172
- ))
173
- except OSError as exc:
174
- results.append(InstallResult(
175
- cli_name=cli_name, action="failed",
176
- path=path, message=f"Failed to remove {path}: {exc}",
177
- ))
178
- else:
179
- results.append(InstallResult(
180
- cli_name=cli_name, action="skipped",
181
- path=path, message=f"Nothing to remove at {path}",
182
- ))
183
- return results
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes