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.
- {paircode-0.8.1 → paircode-0.10.1}/PKG-INFO +11 -8
- {paircode-0.8.1 → paircode-0.10.1}/README.md +9 -6
- {paircode-0.8.1 → paircode-0.10.1}/pyproject.toml +2 -2
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/__init__.py +1 -1
- paircode-0.10.1/src/paircode/installer.py +322 -0
- paircode-0.10.1/src/paircode/templates/codex_slash_command.md +24 -0
- paircode-0.10.1/src/paircode/templates/gemini_slash_command.toml +25 -0
- {paircode-0.8.1 → paircode-0.10.1}/tests/test_cli_smoke.py +132 -7
- {paircode-0.8.1 → paircode-0.10.1}/tests/test_smoke.py +50 -21
- paircode-0.8.1/src/paircode/installer.py +0 -183
- {paircode-0.8.1 → paircode-0.10.1}/.gitignore +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/LICENSE +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/__main__.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/cli.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/detect.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/drive.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/gates.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/handshake.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/journey.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/runner.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/seal.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/state.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/claude_slash_command.md +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/codex_rules.md +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/src/paircode/templates/peers.yaml +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/tests/__init__.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/tests/test_drive.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/tests/test_gates.py +0 -0
- {paircode-0.8.1 → paircode-0.10.1}/tests/test_seal.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
39
|
+
paircode install # registers /paircode in every detected LLM CLI
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
After install,
|
|
42
|
+
After install, `/paircode` is available in all three:
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
11
|
+
paircode install # registers /paircode in every detected LLM CLI
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
After install,
|
|
14
|
+
After install, `/paircode` is available in all three:
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
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.
|
|
42
|
+
"cliworker>=0.8.1",
|
|
43
43
|
]
|
|
44
44
|
|
|
45
45
|
[project.urls]
|
|
@@ -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
|
|
194
|
+
# Installer: native-register for codex + gemini via cliworker.invoke()
|
|
195
195
|
# ---------------------------------------------------------------------------
|
|
196
196
|
|
|
197
|
-
def
|
|
198
|
-
"""
|
|
199
|
-
|
|
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
|
-
|
|
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"] == "
|
|
234
|
+
assert actions["codex"] == "installed"
|
|
216
235
|
|
|
217
|
-
|
|
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
|
|
58
|
-
"""
|
|
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
|
-
|
|
102
|
+
|
|
80
103
|
assert actions["claude"] == "installed"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
assert
|
|
97
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|