paircode 0.9.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.9.1 → paircode-0.10.1}/PKG-INFO +2 -2
  2. {paircode-0.9.1 → paircode-0.10.1}/pyproject.toml +2 -2
  3. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/__init__.py +1 -1
  4. paircode-0.10.1/src/paircode/installer.py +322 -0
  5. {paircode-0.9.1 → paircode-0.10.1}/tests/test_cli_smoke.py +115 -18
  6. {paircode-0.9.1 → paircode-0.10.1}/tests/test_smoke.py +45 -25
  7. paircode-0.9.1/src/paircode/installer.py +0 -188
  8. {paircode-0.9.1 → paircode-0.10.1}/.gitignore +0 -0
  9. {paircode-0.9.1 → paircode-0.10.1}/LICENSE +0 -0
  10. {paircode-0.9.1 → paircode-0.10.1}/README.md +0 -0
  11. {paircode-0.9.1 → paircode-0.10.1}/diary/001-step-a-architecture.md +0 -0
  12. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/__main__.py +0 -0
  13. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/cli.py +0 -0
  14. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/detect.py +0 -0
  15. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/drive.py +0 -0
  16. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/gates.py +0 -0
  17. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/handshake.py +0 -0
  18. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/journey.py +0 -0
  19. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/runner.py +0 -0
  20. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/seal.py +0 -0
  21. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/state.py +0 -0
  22. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/FOCUS.md +0 -0
  23. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/JOURNEY.md +0 -0
  24. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/claude_slash_command.md +0 -0
  25. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/codex_rules.md +0 -0
  26. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/codex_slash_command.md +0 -0
  27. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/gemini_slash_command.toml +0 -0
  28. {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/peers.yaml +0 -0
  29. {paircode-0.9.1 → paircode-0.10.1}/tests/__init__.py +0 -0
  30. {paircode-0.9.1 → paircode-0.10.1}/tests/test_drive.py +0 -0
  31. {paircode-0.9.1 → paircode-0.10.1}/tests/test_gates.py +0 -0
  32. {paircode-0.9.1 → paircode-0.10.1}/tests/test_seal.py +0 -0
  33. {paircode-0.9.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.9.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "paircode"
7
- version = "0.9.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.9.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)
@@ -191,13 +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_writes_codex_prompt_never_broken_rules_file(tmp_path, monkeypatch):
198
- """Regression of the 'broken codex rules' bug: installer must write to
199
- ~/.codex/prompts/paircode.md (the correct path) and NEVER to
200
- ~/.codex/rules/paircode.rules (that breaks codex's Starlark 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`."""
201
217
  fake_home = tmp_path / "home"
202
218
  fake_home.mkdir()
203
219
  monkeypatch.setenv("HOME", str(fake_home))
@@ -210,17 +226,25 @@ def test_installer_writes_codex_prompt_never_broken_rules_file(tmp_path, monkeyp
210
226
  importlib.reload(inst)
211
227
 
212
228
  monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
213
- results = inst.install_all()
229
+ captured: list[tuple] = []
230
+ monkeypatch.setattr("paircode.installer.invoke", _fake_cliworker_invoke_success(captured))
214
231
 
232
+ results = inst.install_all()
215
233
  actions = {r.cli_name: r.action for r in results}
216
234
  assert actions["codex"] == "installed"
217
- assert (fake_home / ".codex" / "prompts" / "paircode.md").exists()
235
+
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
 
221
- def test_installer_writes_gemini_toml_command(tmp_path, monkeypatch):
222
- """Gemini slash commands are TOML (not markdown). Path must be
223
- ~/.gemini/commands/paircode.toml with description + prompt fields."""
246
+ def test_installer_gemini_calls_extensions_install_via_invoke(tmp_path, monkeypatch):
247
+ """paircode v0.10 runs `gemini extensions install <url> --consent`."""
224
248
  fake_home = tmp_path / "home"
225
249
  fake_home.mkdir()
226
250
  monkeypatch.setenv("HOME", str(fake_home))
@@ -233,17 +257,90 @@ def test_installer_writes_gemini_toml_command(tmp_path, monkeypatch):
233
257
  importlib.reload(inst)
234
258
 
235
259
  monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
236
- results = inst.install_all()
260
+ captured: list[tuple] = []
261
+ monkeypatch.setattr("paircode.installer.invoke", _fake_cliworker_invoke_success(captured))
237
262
 
263
+ results = inst.install_all()
238
264
  actions = {r.cli_name: r.action for r in results}
239
265
  assert actions["gemini"] == "installed"
240
- target = fake_home / ".gemini" / "commands" / "paircode.toml"
241
- assert target.exists()
242
- content = target.read_text()
243
- assert 'description' in content
244
- assert 'prompt' in content
245
- # TOML syntax sanity triple-quoted prompt
246
- assert '"""' in content
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
247
344
 
248
345
 
249
346
  # ---------------------------------------------------------------------------
@@ -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,43 +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
- # v0.9: all three CLIs get a real slash command file drop.
102
+
80
103
  assert actions["claude"] == "installed"
81
104
  assert actions["codex"] == "installed"
82
105
  assert actions["gemini"] == "installed"
83
106
 
84
- # Correct paths per CLI
107
+ # Claude: file-drop landed on disk
85
108
  claude_cmd = fake_home / ".claude" / "commands" / "paircode.md"
86
109
  assert claude_cmd.exists()
87
110
  assert "paircode" in claude_cmd.read_text()
88
111
 
89
- codex_cmd = fake_home / ".codex" / "prompts" / "paircode.md"
90
- assert codex_cmd.exists(), (
91
- f"v0.9 must write codex slash command to {codex_cmd}"
92
- )
93
- assert "paircode" in codex_cmd.read_text()
94
-
95
- gemini_cmd = fake_home / ".gemini" / "commands" / "paircode.toml"
96
- assert gemini_cmd.exists(), (
97
- f"v0.9 must write gemini slash command to {gemini_cmd} (note: TOML, not markdown)"
98
- )
99
- gemini_content = gemini_cmd.read_text()
100
- assert "description" in gemini_content
101
- assert "prompt" in gemini_content
102
-
103
- # And verify we still do NOT write the legacy broken codex rules file
104
- codex_rules = fake_home / ".codex" / "rules" / "paircode.rules"
105
- assert not codex_rules.exists(), (
106
- f"paircode must never write {codex_rules} — that file breaks codex's rule loader"
107
- )
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()
108
128
 
109
129
 
110
130
  def test_install_cleans_legacy_codex_rules_file(tmp_path, monkeypatch):
@@ -1,188 +0,0 @@
1
- """Install paircode's slash command into detected LLM CLIs.
2
-
3
- Strategy per CLI (all file-drop; see v1.0 roadmap for native register migration):
4
- - Claude Code: ~/.claude/commands/paircode.md (Markdown + YAML frontmatter).
5
- First-class documented path.
6
- - Codex CLI: ~/.codex/prompts/paircode.md (Markdown + YAML frontmatter).
7
- Path marked "deprecated" by OpenAI (they're steering toward
8
- marketplaces), but still loads and remains the only file-drop
9
- path for user-typed slash commands in codex.
10
- - Gemini CLI: ~/.gemini/commands/paircode.toml (TOML with description + prompt).
11
- First-class, first-party format. Not Markdown.
12
-
13
- Also cleans up legacy broken files from paircode 0.1–0.7 which wrote
14
- ~/.codex/rules/paircode.rules (that file is Starlark-parsed and our markdown
15
- broke codex's rule loader).
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", action="skipped", path=None,
47
- message=f"claude CLI not on PATH. {info.install_hint}",
48
- )
49
- commands_dir = info.config_dir / "commands"
50
- commands_dir.mkdir(parents=True, exist_ok=True)
51
- target = commands_dir / "paircode.md"
52
- target.write_text(_read_template("claude_slash_command.md"), encoding="utf-8")
53
- return InstallResult(
54
- cli_name="claude", action="installed", path=target,
55
- message=f"Wrote /paircode slash command to {target}.",
56
- )
57
-
58
-
59
- def install_codex(info: CliInfo) -> InstallResult:
60
- """Write ~/.codex/prompts/paircode.md — codex's custom-prompt slash command path.
61
-
62
- Codex flags this path as "deprecated" in favor of marketplace plugins, but
63
- it's still the only file-drop path for user-typed slash commands. When
64
- codex removes this (they haven't yet), paircode will migrate to
65
- `codex marketplace add` with a satellite repo.
66
- """
67
- if not info.installed:
68
- return InstallResult(
69
- cli_name="codex", action="skipped", path=None,
70
- message=f"codex CLI not on PATH. {info.install_hint}",
71
- )
72
- prompts_dir = info.config_dir / "prompts"
73
- prompts_dir.mkdir(parents=True, exist_ok=True)
74
- target = prompts_dir / "paircode.md"
75
- target.write_text(_read_template("codex_slash_command.md"), encoding="utf-8")
76
- return InstallResult(
77
- cli_name="codex", action="installed", path=target,
78
- message=f"Wrote /paircode slash command to {target}. Inside codex interactive, /paircode will appear.",
79
- )
80
-
81
-
82
- def install_gemini(info: CliInfo) -> InstallResult:
83
- """Write ~/.gemini/commands/paircode.toml — gemini's first-class custom command path.
84
-
85
- Gemini uses TOML (not Markdown) with `description` and `prompt` fields.
86
- After install, run `/commands reload` inside gemini or restart the session.
87
- """
88
- if not info.installed:
89
- return InstallResult(
90
- cli_name="gemini", action="skipped", path=None,
91
- message=f"gemini CLI not on PATH. {info.install_hint}",
92
- )
93
- commands_dir = info.config_dir / "commands"
94
- commands_dir.mkdir(parents=True, exist_ok=True)
95
- target = commands_dir / "paircode.toml"
96
- target.write_text(_read_template("gemini_slash_command.toml"), encoding="utf-8")
97
- return InstallResult(
98
- cli_name="gemini", action="installed", path=target,
99
- message=(
100
- f"Wrote /paircode slash command to {target}. "
101
- "Run `/commands reload` inside gemini (or restart) to pick it up."
102
- ),
103
- )
104
-
105
-
106
- # ---------------------------------------------------------------------------
107
- # Public entry points
108
- # ---------------------------------------------------------------------------
109
-
110
- def install_all() -> list[InstallResult]:
111
- """Run every CLI's installer. Also cleans up legacy broken files first."""
112
- _clean_legacy_broken_files()
113
- detected = detect_all()
114
- installers = {
115
- "claude": install_claude,
116
- "codex": install_codex,
117
- "gemini": install_gemini,
118
- }
119
- results: list[InstallResult] = []
120
- for name, info in detected.items():
121
- installer = installers.get(name)
122
- if not installer:
123
- continue
124
- try:
125
- results.append(installer(info))
126
- except Exception as exc: # pragma: no cover — defensive
127
- results.append(InstallResult(
128
- cli_name=name, action="failed", path=None,
129
- message=f"{type(exc).__name__}: {exc}",
130
- ))
131
- return results
132
-
133
-
134
- def _clean_legacy_broken_files() -> None:
135
- """Remove files prior paircode versions installed and which we now know
136
- to be broken or misplaced. Silent best-effort."""
137
- # v0.1–v0.7 wrote markdown to codex's Starlark rules dir, which broke
138
- # codex's rule loader on every session startup.
139
- legacy = Path.home() / ".codex" / "rules" / "paircode.rules"
140
- try:
141
- if legacy.exists():
142
- legacy.unlink()
143
- except OSError:
144
- pass
145
-
146
- # v0.1–v0.7 also dropped a reference file at ~/.gemini/paircode.md.
147
- # v0.9+ writes the real slash command at ~/.gemini/commands/paircode.toml
148
- # instead; clean up the old reference.
149
- legacy_gemini = Path.home() / ".gemini" / "paircode.md"
150
- try:
151
- if legacy_gemini.exists():
152
- legacy_gemini.unlink()
153
- except OSError:
154
- pass
155
-
156
-
157
- def uninstall_all() -> list[InstallResult]:
158
- """Remove paircode entries from every CLI config dir (idempotent).
159
- Also cleans up legacy broken paths from prior paircode versions."""
160
- results: list[InstallResult] = []
161
- paths: list[tuple[Path, str]] = [
162
- # Current v0.9+ paths
163
- (Path.home() / ".claude" / "commands" / "paircode.md", "claude"),
164
- (Path.home() / ".codex" / "prompts" / "paircode.md", "codex"),
165
- (Path.home() / ".gemini" / "commands" / "paircode.toml", "gemini"),
166
- # Legacy paths from older paircode versions
167
- (Path.home() / ".codex" / "rules" / "paircode.rules", "codex-legacy"),
168
- (Path.home() / ".gemini" / "paircode.md", "gemini-legacy"),
169
- ]
170
- for path, cli_name in paths:
171
- if path.exists():
172
- try:
173
- path.unlink()
174
- results.append(InstallResult(
175
- cli_name=cli_name, action="installed", # "removed"
176
- path=path, message=f"Removed {path}",
177
- ))
178
- except OSError as exc:
179
- results.append(InstallResult(
180
- cli_name=cli_name, action="failed",
181
- path=path, message=f"Failed to remove {path}: {exc}",
182
- ))
183
- else:
184
- results.append(InstallResult(
185
- cli_name=cli_name, action="skipped",
186
- path=path, message=f"Nothing to remove at {path}",
187
- ))
188
- 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
File without changes