paircode 0.7.1__tar.gz → 0.9.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.7.1 → paircode-0.9.1}/PKG-INFO +20 -6
- {paircode-0.7.1 → paircode-0.9.1}/README.md +15 -2
- {paircode-0.7.1 → paircode-0.9.1}/pyproject.toml +5 -4
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/__init__.py +1 -1
- paircode-0.9.1/src/paircode/installer.py +188 -0
- paircode-0.9.1/src/paircode/runner.py +122 -0
- paircode-0.9.1/src/paircode/templates/codex_slash_command.md +24 -0
- paircode-0.9.1/src/paircode/templates/gemini_slash_command.toml +25 -0
- paircode-0.9.1/tests/test_cli_smoke.py +280 -0
- {paircode-0.7.1 → paircode-0.9.1}/tests/test_smoke.py +59 -3
- paircode-0.7.1/src/paircode/installer.py +0 -161
- paircode-0.7.1/src/paircode/runner.py +0 -114
- {paircode-0.7.1 → paircode-0.9.1}/.gitignore +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/LICENSE +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/__main__.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/cli.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/detect.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/drive.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/gates.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/handshake.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/journey.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/seal.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/state.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/claude_slash_command.md +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/codex_rules.md +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/peers.yaml +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/tests/__init__.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/tests/test_drive.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/tests/test_gates.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/tests/test_seal.py +0 -0
- {paircode-0.7.1 → paircode-0.9.1}/tests/test_state.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paircode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review.
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Repository, https://github.com/
|
|
7
|
-
Project-URL: Issues, https://github.com/
|
|
5
|
+
Project-URL: Homepage, https://github.com/starshipagentic/paircode
|
|
6
|
+
Project-URL: Repository, https://github.com/starshipagentic/paircode
|
|
7
|
+
Project-URL: Issues, https://github.com/starshipagentic/paircode/issues
|
|
8
8
|
Author-email: Travis Somerville <travis.somerville@gmail.com>
|
|
9
9
|
License: MIT
|
|
10
10
|
License-File: LICENSE
|
|
@@ -21,6 +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
25
|
Requires-Dist: pyyaml>=6.0
|
|
25
26
|
Requires-Dist: rich>=13.0
|
|
26
27
|
Description-Content-Type: text/markdown
|
|
@@ -35,10 +36,23 @@ Description-Content-Type: text/markdown
|
|
|
35
36
|
|
|
36
37
|
```bash
|
|
37
38
|
pipx install paircode # or: pip install --user paircode
|
|
38
|
-
paircode install # registers /paircode in
|
|
39
|
+
paircode install # registers /paircode in every detected LLM CLI
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
After install, `/paircode` is available in all three:
|
|
43
|
+
|
|
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.
|
|
51
|
+
|
|
52
|
+
As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
|
|
53
|
+
the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
|
|
54
|
+
logic. paircode adds the peer-review orchestration on top (file-traces, stages,
|
|
55
|
+
gates, journey).
|
|
42
56
|
|
|
43
57
|
## Use it — three entry points
|
|
44
58
|
|
|
@@ -8,10 +8,23 @@
|
|
|
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
|
-
|
|
14
|
+
After install, `/paircode` is available in all three:
|
|
15
|
+
|
|
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.
|
|
23
|
+
|
|
24
|
+
As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
|
|
25
|
+
the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
|
|
26
|
+
logic. paircode adds the peer-review orchestration on top (file-traces, stages,
|
|
27
|
+
gates, journey).
|
|
15
28
|
|
|
16
29
|
## Use it — three entry points
|
|
17
30
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paircode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.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,12 +39,13 @@ dependencies = [
|
|
|
39
39
|
"click>=8.1",
|
|
40
40
|
"pyyaml>=6.0",
|
|
41
41
|
"rich>=13.0",
|
|
42
|
+
"cliworker>=0.7.1",
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
[project.urls]
|
|
45
|
-
Homepage = "https://github.com/
|
|
46
|
-
Repository = "https://github.com/
|
|
47
|
-
Issues = "https://github.com/
|
|
46
|
+
Homepage = "https://github.com/starshipagentic/paircode"
|
|
47
|
+
Repository = "https://github.com/starshipagentic/paircode"
|
|
48
|
+
Issues = "https://github.com/starshipagentic/paircode/issues"
|
|
48
49
|
|
|
49
50
|
[project.scripts]
|
|
50
51
|
paircode = "paircode.cli:main"
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Invoke peer LLM CLIs via cliworker, capture output to .md files.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over `cliworker.run()`. Gives paircode:
|
|
4
|
+
* CLAUDE_FAST flags when fast=True (claude -p goes from 18s → 4s)
|
|
5
|
+
* Gemini MCP strip-and-restore during calls
|
|
6
|
+
* Skip-cache for broken engines (1h TTL)
|
|
7
|
+
* Subscription-mode-first by default (no API credits unless paid_ok=True)
|
|
8
|
+
* One place to fix CLI quirks instead of re-implementing them here
|
|
9
|
+
|
|
10
|
+
Adds one paircode-specific thing cliworker doesn't: writing each peer's
|
|
11
|
+
response to a .md file with a file-trace header (peer_id / cli / model /
|
|
12
|
+
duration / ok). That's the orchestration log paircode needs.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from cliworker import CLIResult, get_spec, run
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT_SECONDS = 600 # 10 min per peer call — generous for cold research
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PeerRunResult:
|
|
28
|
+
"""paircode's peer-run result — wraps cliworker's CLIResult with a peer_id."""
|
|
29
|
+
peer_id: str
|
|
30
|
+
cli: str
|
|
31
|
+
ok: bool
|
|
32
|
+
stdout: str
|
|
33
|
+
stderr: str
|
|
34
|
+
duration_s: float
|
|
35
|
+
command: list[str]
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_cli_result(cls, peer_id: str, r: CLIResult) -> "PeerRunResult":
|
|
39
|
+
return cls(
|
|
40
|
+
peer_id=peer_id,
|
|
41
|
+
cli=r.spec.cli,
|
|
42
|
+
ok=r.ok,
|
|
43
|
+
stdout=r.stdout,
|
|
44
|
+
stderr=r.stderr,
|
|
45
|
+
duration_s=r.duration_s,
|
|
46
|
+
command=list(r.argv),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_peer(
|
|
51
|
+
peer_id: str,
|
|
52
|
+
cli: str,
|
|
53
|
+
prompt: str,
|
|
54
|
+
output_path: Path,
|
|
55
|
+
model: Optional[str] = None,
|
|
56
|
+
timeout_s: int = DEFAULT_TIMEOUT_SECONDS,
|
|
57
|
+
fast: bool = False,
|
|
58
|
+
paid_ok: bool | list[str] | None = None,
|
|
59
|
+
) -> PeerRunResult:
|
|
60
|
+
"""Run one peer LLM against `prompt`, write its stdout to `output_path`.
|
|
61
|
+
|
|
62
|
+
Delegates to `cliworker.run()` for the actual subprocess call. Adds the
|
|
63
|
+
paircode-specific file-trace header. Output file is written regardless
|
|
64
|
+
of success — every invocation leaves a trace on disk.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
peer_id: paircode-level identifier (e.g. "peer-a-codex"). Not known to cliworker.
|
|
68
|
+
cli: one of "claude", "codex", "gemini", "ollama".
|
|
69
|
+
prompt: the instruction to send.
|
|
70
|
+
output_path: where to write the file-trace .md.
|
|
71
|
+
model: optional model override (e.g. "sonnet", "gemma3:4b").
|
|
72
|
+
timeout_s: per-call timeout.
|
|
73
|
+
fast: True = apply cliworker's speed flags (CLAUDE_FAST, gemini MCP strip).
|
|
74
|
+
False (default) = full mode — all MCPs/tools loaded, matches how
|
|
75
|
+
the user's normal `claude -p` session behaves.
|
|
76
|
+
paid_ok: None (default) = free/subscription only, never burn API credits.
|
|
77
|
+
True = allow paid API fallback for this CLI.
|
|
78
|
+
list[str] = allow paid only for those CLI names (typically just `cli`).
|
|
79
|
+
"""
|
|
80
|
+
# Build spec with optional model override
|
|
81
|
+
spec = get_spec(cli, model=model) if model else get_spec(cli)
|
|
82
|
+
|
|
83
|
+
# cliworker's run() needs None (not False) to "respect spec default" for fast.
|
|
84
|
+
results = run(
|
|
85
|
+
prompt, spec,
|
|
86
|
+
fast=True if fast else None,
|
|
87
|
+
paid_ok=paid_ok,
|
|
88
|
+
timeout_s=timeout_s,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# cliworker.run() returns empty list only if no CLIs passed AND no default chain.
|
|
92
|
+
# We passed an explicit spec, so this should always have at least one result.
|
|
93
|
+
if results:
|
|
94
|
+
cli_result = results[-1]
|
|
95
|
+
else:
|
|
96
|
+
# Defensive fallback — construct a failure result
|
|
97
|
+
cli_result = CLIResult(
|
|
98
|
+
spec=spec, ok=False, stdout="",
|
|
99
|
+
stderr=f"no result from cliworker for {cli}",
|
|
100
|
+
duration_s=0.0, returncode=None, argv=[], skipped_reason=None,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Write the paircode file-trace
|
|
104
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
header = (
|
|
106
|
+
f"<!-- peer_id: {peer_id} -->\n"
|
|
107
|
+
f"<!-- cli: {cli} -->\n"
|
|
108
|
+
f"<!-- model: {model or '(default)'} -->\n"
|
|
109
|
+
f"<!-- duration_s: {cli_result.duration_s:.1f} -->\n"
|
|
110
|
+
f"<!-- ok: {cli_result.ok} -->\n\n"
|
|
111
|
+
)
|
|
112
|
+
if cli_result.ok:
|
|
113
|
+
body = cli_result.stdout
|
|
114
|
+
else:
|
|
115
|
+
body = (
|
|
116
|
+
f"# Peer run FAILED\n\n"
|
|
117
|
+
f"```\n{cli_result.stderr}\n```\n\n"
|
|
118
|
+
f"{cli_result.stdout}"
|
|
119
|
+
)
|
|
120
|
+
output_path.write_text(header + body, encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
return PeerRunResult.from_cli_result(peer_id, cli_result)
|
|
@@ -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
|
+
"""
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""End-to-end CLI smoke tests for paircode (v0.8+).
|
|
2
|
+
|
|
3
|
+
Invokes the real CLI via CliRunner and asserts on real output. Catches
|
|
4
|
+
regressions unit tests miss: broken subcommands, click routing drift,
|
|
5
|
+
wrong default behavior, missing help content.
|
|
6
|
+
|
|
7
|
+
Mirrors the same approach we added to cliworker in its v0.5.5.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from click.testing import CliRunner
|
|
15
|
+
|
|
16
|
+
from paircode import __version__
|
|
17
|
+
from paircode.cli import main
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Help-lint: every registered command + every --help variant must work.
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
@pytest.mark.parametrize(
|
|
25
|
+
"cmd_argv",
|
|
26
|
+
[
|
|
27
|
+
["--help"],
|
|
28
|
+
["--version"],
|
|
29
|
+
["install", "--help"],
|
|
30
|
+
["uninstall", "--help"],
|
|
31
|
+
["handshake", "--help"],
|
|
32
|
+
["status", "--help"],
|
|
33
|
+
["init", "--help"],
|
|
34
|
+
["focus", "--help"],
|
|
35
|
+
["stage", "--help"],
|
|
36
|
+
["seal", "--help"],
|
|
37
|
+
["drive", "--help"],
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
def test_every_command_help_is_reachable(cmd_argv):
|
|
41
|
+
runner = CliRunner()
|
|
42
|
+
result = runner.invoke(main, cmd_argv)
|
|
43
|
+
assert result.exit_code == 0, (
|
|
44
|
+
f"{cmd_argv} exited {result.exit_code}:\n{result.output}"
|
|
45
|
+
)
|
|
46
|
+
assert result.output.strip(), f"{cmd_argv} printed nothing"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_main_help_lists_all_public_subcommands():
|
|
50
|
+
runner = CliRunner()
|
|
51
|
+
result = runner.invoke(main, ["--help"])
|
|
52
|
+
assert result.exit_code == 0
|
|
53
|
+
for cmd in (
|
|
54
|
+
"install", "uninstall", "handshake", "status",
|
|
55
|
+
"init", "focus", "stage", "seal", "drive",
|
|
56
|
+
):
|
|
57
|
+
assert cmd in result.output, f"Main --help missing subcommand: {cmd}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_version_string_matches_dunder():
|
|
61
|
+
runner = CliRunner()
|
|
62
|
+
result = runner.invoke(main, ["--version"])
|
|
63
|
+
assert result.exit_code == 0
|
|
64
|
+
assert __version__ in result.output
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Runner <-> cliworker integration
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def test_runner_imports_cliworker():
|
|
72
|
+
"""Regression: paircode.runner must import and delegate to cliworker as
|
|
73
|
+
of v0.8+. If someone reverts the refactor, this test breaks."""
|
|
74
|
+
import paircode.runner as runner
|
|
75
|
+
import cliworker
|
|
76
|
+
|
|
77
|
+
# The run_peer function should internally call cliworker.run
|
|
78
|
+
source = Path(runner.__file__).read_text()
|
|
79
|
+
assert "from cliworker" in source, (
|
|
80
|
+
"paircode.runner must use cliworker — "
|
|
81
|
+
"the whole point of v0.8 was to delegate to it"
|
|
82
|
+
)
|
|
83
|
+
assert "cliworker.run" in source or "import run" in source
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_run_peer_wraps_cliworker_result(monkeypatch, tmp_path):
|
|
87
|
+
"""run_peer should call cliworker.run() and return a PeerRunResult
|
|
88
|
+
with the peer_id set + the file-trace written to disk."""
|
|
89
|
+
from paircode.runner import run_peer, PeerRunResult
|
|
90
|
+
|
|
91
|
+
# Intercept cliworker.run so no real subprocess fires
|
|
92
|
+
from cliworker.core import CLIResult
|
|
93
|
+
from cliworker import get_spec
|
|
94
|
+
|
|
95
|
+
def fake_run(prompt, *specs, **kwargs):
|
|
96
|
+
spec = specs[0] if specs else get_spec("claude")
|
|
97
|
+
return [CLIResult(
|
|
98
|
+
spec=spec, ok=True, stdout="mocked answer",
|
|
99
|
+
stderr="", duration_s=0.42, returncode=0,
|
|
100
|
+
argv=["fake", "claude", "-p", prompt], skipped_reason=None,
|
|
101
|
+
)]
|
|
102
|
+
|
|
103
|
+
monkeypatch.setattr("paircode.runner.run", fake_run)
|
|
104
|
+
|
|
105
|
+
out_path = tmp_path / "peer-a-v1.md"
|
|
106
|
+
result = run_peer(
|
|
107
|
+
peer_id="peer-a-fake",
|
|
108
|
+
cli="claude",
|
|
109
|
+
prompt="what is TCP?",
|
|
110
|
+
output_path=out_path,
|
|
111
|
+
timeout_s=30,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Returns a proper PeerRunResult
|
|
115
|
+
assert isinstance(result, PeerRunResult)
|
|
116
|
+
assert result.peer_id == "peer-a-fake"
|
|
117
|
+
assert result.cli == "claude"
|
|
118
|
+
assert result.ok is True
|
|
119
|
+
assert result.stdout == "mocked answer"
|
|
120
|
+
assert result.duration_s == pytest.approx(0.42)
|
|
121
|
+
|
|
122
|
+
# File-trace landed with the correct header
|
|
123
|
+
assert out_path.exists()
|
|
124
|
+
content = out_path.read_text()
|
|
125
|
+
assert "peer_id: peer-a-fake" in content
|
|
126
|
+
assert "cli: claude" in content
|
|
127
|
+
assert "ok: True" in content
|
|
128
|
+
assert "mocked answer" in content
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_run_peer_with_failure_still_writes_trace(monkeypatch, tmp_path):
|
|
132
|
+
"""Failed peer runs must still leave a file-trace on disk, with the
|
|
133
|
+
failure captured — that's the whole point of 'every thought on disk'."""
|
|
134
|
+
from paircode.runner import run_peer
|
|
135
|
+
from cliworker.core import CLIResult
|
|
136
|
+
from cliworker import get_spec
|
|
137
|
+
|
|
138
|
+
def fake_run(prompt, *specs, **kwargs):
|
|
139
|
+
spec = specs[0] if specs else get_spec("claude")
|
|
140
|
+
return [CLIResult(
|
|
141
|
+
spec=spec, ok=False, stdout="",
|
|
142
|
+
stderr="subscription lapsed",
|
|
143
|
+
duration_s=1.5, returncode=1, argv=[], skipped_reason=None,
|
|
144
|
+
)]
|
|
145
|
+
|
|
146
|
+
monkeypatch.setattr("paircode.runner.run", fake_run)
|
|
147
|
+
|
|
148
|
+
out_path = tmp_path / "peer-v1.md"
|
|
149
|
+
result = run_peer(
|
|
150
|
+
peer_id="peer-x", cli="claude", prompt="hi",
|
|
151
|
+
output_path=out_path,
|
|
152
|
+
)
|
|
153
|
+
assert result.ok is False
|
|
154
|
+
assert out_path.exists()
|
|
155
|
+
content = out_path.read_text()
|
|
156
|
+
assert "FAILED" in content
|
|
157
|
+
assert "subscription lapsed" in content
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_run_peer_fast_kwarg_passes_through(monkeypatch, tmp_path):
|
|
161
|
+
"""run_peer(fast=True) must land as fast=True on cliworker.run()."""
|
|
162
|
+
from paircode.runner import run_peer
|
|
163
|
+
from cliworker.core import CLIResult
|
|
164
|
+
from cliworker import get_spec
|
|
165
|
+
|
|
166
|
+
captured = {}
|
|
167
|
+
|
|
168
|
+
def fake_run(prompt, *specs, **kwargs):
|
|
169
|
+
captured["fast"] = kwargs.get("fast")
|
|
170
|
+
return [CLIResult(
|
|
171
|
+
spec=specs[0], ok=True, stdout="ok", stderr="",
|
|
172
|
+
duration_s=0.1, returncode=0, argv=[], skipped_reason=None,
|
|
173
|
+
)]
|
|
174
|
+
|
|
175
|
+
monkeypatch.setattr("paircode.runner.run", fake_run)
|
|
176
|
+
|
|
177
|
+
run_peer(
|
|
178
|
+
peer_id="p", cli="claude", prompt="hi",
|
|
179
|
+
output_path=tmp_path / "x.md",
|
|
180
|
+
fast=True,
|
|
181
|
+
)
|
|
182
|
+
assert captured["fast"] is True
|
|
183
|
+
|
|
184
|
+
# And default: no fast
|
|
185
|
+
captured.clear()
|
|
186
|
+
run_peer(
|
|
187
|
+
peer_id="p", cli="claude", prompt="hi",
|
|
188
|
+
output_path=tmp_path / "y.md",
|
|
189
|
+
)
|
|
190
|
+
assert captured.get("fast") in (None, False)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Installer: codex no-op + legacy cleanup
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
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)."""
|
|
201
|
+
fake_home = tmp_path / "home"
|
|
202
|
+
fake_home.mkdir()
|
|
203
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
204
|
+
import importlib
|
|
205
|
+
|
|
206
|
+
import paircode.detect as d
|
|
207
|
+
import paircode.installer as inst
|
|
208
|
+
|
|
209
|
+
importlib.reload(d)
|
|
210
|
+
importlib.reload(inst)
|
|
211
|
+
|
|
212
|
+
monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
|
|
213
|
+
results = inst.install_all()
|
|
214
|
+
|
|
215
|
+
actions = {r.cli_name: r.action for r in results}
|
|
216
|
+
assert actions["codex"] == "installed"
|
|
217
|
+
assert (fake_home / ".codex" / "prompts" / "paircode.md").exists()
|
|
218
|
+
assert not (fake_home / ".codex" / "rules" / "paircode.rules").exists()
|
|
219
|
+
|
|
220
|
+
|
|
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."""
|
|
224
|
+
fake_home = tmp_path / "home"
|
|
225
|
+
fake_home.mkdir()
|
|
226
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
227
|
+
import importlib
|
|
228
|
+
|
|
229
|
+
import paircode.detect as d
|
|
230
|
+
import paircode.installer as inst
|
|
231
|
+
|
|
232
|
+
importlib.reload(d)
|
|
233
|
+
importlib.reload(inst)
|
|
234
|
+
|
|
235
|
+
monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
|
|
236
|
+
results = inst.install_all()
|
|
237
|
+
|
|
238
|
+
actions = {r.cli_name: r.action for r in results}
|
|
239
|
+
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
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Init + status dispatch smoke
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def test_init_creates_paircode_dir(tmp_path, monkeypatch):
|
|
254
|
+
monkeypatch.chdir(tmp_path)
|
|
255
|
+
runner = CliRunner()
|
|
256
|
+
result = runner.invoke(main, ["init"])
|
|
257
|
+
assert result.exit_code == 0, result.output
|
|
258
|
+
assert (tmp_path / ".paircode").exists()
|
|
259
|
+
assert (tmp_path / ".paircode" / "JOURNEY.md").exists()
|
|
260
|
+
assert (tmp_path / ".paircode" / "peers.yaml").exists()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_status_finds_paircode_dir(tmp_path, monkeypatch):
|
|
264
|
+
monkeypatch.chdir(tmp_path)
|
|
265
|
+
runner = CliRunner()
|
|
266
|
+
# First init
|
|
267
|
+
runner.invoke(main, ["init"])
|
|
268
|
+
# Then status should find it and exit cleanly
|
|
269
|
+
result = runner.invoke(main, ["status"])
|
|
270
|
+
assert result.exit_code == 0
|
|
271
|
+
assert ".paircode" in result.output
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_status_without_paircode_dir_prints_hint(tmp_path, monkeypatch):
|
|
275
|
+
monkeypatch.chdir(tmp_path)
|
|
276
|
+
runner = CliRunner()
|
|
277
|
+
result = runner.invoke(main, ["status"])
|
|
278
|
+
# Should not crash; should suggest init
|
|
279
|
+
assert result.exit_code == 0
|
|
280
|
+
assert "init" in result.output.lower()
|
|
@@ -76,9 +76,65 @@ def test_install_writes_to_tmp_claude(tmp_path, monkeypatch):
|
|
|
76
76
|
|
|
77
77
|
results = inst.install_all()
|
|
78
78
|
actions = {r.cli_name: r.action for r in results}
|
|
79
|
+
# v0.9: all three CLIs get a real slash command file drop.
|
|
79
80
|
assert actions["claude"] == "installed"
|
|
80
|
-
|
|
81
|
+
assert actions["codex"] == "installed"
|
|
82
|
+
assert actions["gemini"] == "installed"
|
|
83
|
+
|
|
84
|
+
# Correct paths per CLI
|
|
81
85
|
claude_cmd = fake_home / ".claude" / "commands" / "paircode.md"
|
|
82
86
|
assert claude_cmd.exists()
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
assert "paircode" in claude_cmd.read_text()
|
|
88
|
+
|
|
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
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_install_cleans_legacy_codex_rules_file(tmp_path, monkeypatch):
|
|
111
|
+
"""Users who installed paircode 0.1–0.7 have a broken ~/.codex/rules/paircode.rules.
|
|
112
|
+
Running install again (or any time) must clean it up."""
|
|
113
|
+
fake_home = tmp_path / "home"
|
|
114
|
+
fake_home.mkdir()
|
|
115
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
116
|
+
import importlib
|
|
117
|
+
|
|
118
|
+
import paircode.detect as d
|
|
119
|
+
import paircode.installer as inst
|
|
120
|
+
|
|
121
|
+
importlib.reload(d)
|
|
122
|
+
importlib.reload(inst)
|
|
123
|
+
|
|
124
|
+
# Seed the legacy broken file
|
|
125
|
+
legacy = fake_home / ".codex" / "rules" / "paircode.rules"
|
|
126
|
+
legacy.parent.mkdir(parents=True)
|
|
127
|
+
legacy.write_text("# old broken markdown-in-starlark\n")
|
|
128
|
+
assert legacy.exists()
|
|
129
|
+
|
|
130
|
+
def fake_which(binary):
|
|
131
|
+
return f"/fake/bin/{binary}"
|
|
132
|
+
|
|
133
|
+
monkeypatch.setattr("shutil.which", fake_which)
|
|
134
|
+
inst.install_all()
|
|
135
|
+
|
|
136
|
+
# Legacy file should be gone after install_all
|
|
137
|
+
assert not legacy.exists(), (
|
|
138
|
+
"install_all must remove the legacy ~/.codex/rules/paircode.rules "
|
|
139
|
+
"(it's Starlark-parsed and our markdown broke codex)"
|
|
140
|
+
)
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
"""Install paircode's slash command / rules into the detected LLM CLIs.
|
|
2
|
-
|
|
3
|
-
Strategy per CLI:
|
|
4
|
-
- Claude Code: write `~/.claude/commands/paircode.md` (user-level global slash command).
|
|
5
|
-
- Codex: write `~/.codex/prompts/paircode.md` if codex supports it, otherwise write
|
|
6
|
-
a rules snippet at `~/.codex/rules/paircode.rules` that gets picked up as context.
|
|
7
|
-
- Gemini: no global slash-command primitive; install as a skill via `gemini skills install`
|
|
8
|
-
or write an extension manifest. Fallback: print instructions to invoke `paircode` from shell.
|
|
9
|
-
"""
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import shutil
|
|
13
|
-
from dataclasses import dataclass
|
|
14
|
-
from importlib import resources
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
from paircode.detect import detect_all, CliInfo
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@dataclass(frozen=True)
|
|
21
|
-
class InstallResult:
|
|
22
|
-
cli_name: str
|
|
23
|
-
action: str # "installed", "skipped", "failed"
|
|
24
|
-
path: Path | None
|
|
25
|
-
message: str
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _read_template(name: str) -> str:
|
|
29
|
-
return resources.files("paircode.templates").joinpath(name).read_text(encoding="utf-8")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def install_claude(info: CliInfo) -> InstallResult:
|
|
33
|
-
if not info.installed:
|
|
34
|
-
return InstallResult(
|
|
35
|
-
cli_name="claude",
|
|
36
|
-
action="skipped",
|
|
37
|
-
path=None,
|
|
38
|
-
message=f"claude CLI not on PATH. {info.install_hint}",
|
|
39
|
-
)
|
|
40
|
-
commands_dir = info.config_dir / "commands"
|
|
41
|
-
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
-
target = commands_dir / "paircode.md"
|
|
43
|
-
template = _read_template("claude_slash_command.md")
|
|
44
|
-
target.write_text(template, encoding="utf-8")
|
|
45
|
-
return InstallResult(
|
|
46
|
-
cli_name="claude",
|
|
47
|
-
action="installed",
|
|
48
|
-
path=target,
|
|
49
|
-
message=f"Wrote /paircode slash command to {target}. Use it from any Claude Code session.",
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def install_codex(info: CliInfo) -> InstallResult:
|
|
54
|
-
if not info.installed:
|
|
55
|
-
return InstallResult(
|
|
56
|
-
cli_name="codex",
|
|
57
|
-
action="skipped",
|
|
58
|
-
path=None,
|
|
59
|
-
message=f"codex CLI not on PATH. {info.install_hint}",
|
|
60
|
-
)
|
|
61
|
-
# Codex doesn't have a user-facing slash-command dir; we write a rules snippet
|
|
62
|
-
# that codex reads as context in every session. This is graceful fallback.
|
|
63
|
-
rules_dir = info.config_dir / "rules"
|
|
64
|
-
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
-
target = rules_dir / "paircode.rules"
|
|
66
|
-
template = _read_template("codex_rules.md")
|
|
67
|
-
target.write_text(template, encoding="utf-8")
|
|
68
|
-
return InstallResult(
|
|
69
|
-
cli_name="codex",
|
|
70
|
-
action="installed",
|
|
71
|
-
path=target,
|
|
72
|
-
message=(
|
|
73
|
-
f"Wrote paircode rules to {target}. Codex will see paircode as an "
|
|
74
|
-
"available tool in its context. Invoke via `codex exec 'paircode ...'`."
|
|
75
|
-
),
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def install_gemini(info: CliInfo) -> InstallResult:
|
|
80
|
-
if not info.installed:
|
|
81
|
-
return InstallResult(
|
|
82
|
-
cli_name="gemini",
|
|
83
|
-
action="skipped",
|
|
84
|
-
path=None,
|
|
85
|
-
message=f"gemini CLI not on PATH. {info.install_hint}",
|
|
86
|
-
)
|
|
87
|
-
# Gemini CLI exposes `gemini skills install <source>`, but we don't want to
|
|
88
|
-
# depend on the gemini binary being on PATH at install time. Instead, we
|
|
89
|
-
# document the invocation pattern: gemini users call paircode from shell or
|
|
90
|
-
# via a skill installed manually. Write a reminder file at ~/.gemini/paircode.md.
|
|
91
|
-
info.config_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
-
target = info.config_dir / "paircode.md"
|
|
93
|
-
template = _read_template("codex_rules.md") # same "you have paircode CLI" content
|
|
94
|
-
target.write_text(template, encoding="utf-8")
|
|
95
|
-
return InstallResult(
|
|
96
|
-
cli_name="gemini",
|
|
97
|
-
action="installed",
|
|
98
|
-
path=target,
|
|
99
|
-
message=(
|
|
100
|
-
f"Wrote paircode reference to {target}. Gemini users: invoke via "
|
|
101
|
-
"`gemini -p 'run paircode status'` or from shell directly. Full "
|
|
102
|
-
"skill-registration support lands in a later release."
|
|
103
|
-
),
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def install_all() -> list[InstallResult]:
|
|
108
|
-
detected = detect_all()
|
|
109
|
-
installers = {
|
|
110
|
-
"claude": install_claude,
|
|
111
|
-
"codex": install_codex,
|
|
112
|
-
"gemini": install_gemini,
|
|
113
|
-
}
|
|
114
|
-
results: list[InstallResult] = []
|
|
115
|
-
for name, info in detected.items():
|
|
116
|
-
installer = installers.get(name)
|
|
117
|
-
if not installer:
|
|
118
|
-
continue
|
|
119
|
-
try:
|
|
120
|
-
results.append(installer(info))
|
|
121
|
-
except Exception as exc: # pragma: no cover — defensive
|
|
122
|
-
results.append(
|
|
123
|
-
InstallResult(
|
|
124
|
-
cli_name=name,
|
|
125
|
-
action="failed",
|
|
126
|
-
path=None,
|
|
127
|
-
message=f"{type(exc).__name__}: {exc}",
|
|
128
|
-
)
|
|
129
|
-
)
|
|
130
|
-
return results
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def uninstall_all() -> list[InstallResult]:
|
|
134
|
-
"""Remove paircode entries from every known CLI config dir (idempotent)."""
|
|
135
|
-
results: list[InstallResult] = []
|
|
136
|
-
paths = [
|
|
137
|
-
(Path.home() / ".claude" / "commands" / "paircode.md", "claude"),
|
|
138
|
-
(Path.home() / ".codex" / "rules" / "paircode.rules", "codex"),
|
|
139
|
-
(Path.home() / ".gemini" / "paircode.md", "gemini"),
|
|
140
|
-
]
|
|
141
|
-
for path, cli_name in paths:
|
|
142
|
-
if path.exists():
|
|
143
|
-
path.unlink()
|
|
144
|
-
results.append(
|
|
145
|
-
InstallResult(
|
|
146
|
-
cli_name=cli_name,
|
|
147
|
-
action="installed", # we removed it
|
|
148
|
-
path=path,
|
|
149
|
-
message=f"Removed {path}",
|
|
150
|
-
)
|
|
151
|
-
)
|
|
152
|
-
else:
|
|
153
|
-
results.append(
|
|
154
|
-
InstallResult(
|
|
155
|
-
cli_name=cli_name,
|
|
156
|
-
action="skipped",
|
|
157
|
-
path=path,
|
|
158
|
-
message=f"Nothing to remove at {path}",
|
|
159
|
-
)
|
|
160
|
-
)
|
|
161
|
-
return results
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
"""Invoke peer LLM CLIs as subprocesses, capture output to .md files.
|
|
2
|
-
|
|
3
|
-
This is the "spawn an LLM, write its answer to disk" primitive. Each CLI
|
|
4
|
-
has different flags for non-interactive / prompt-mode; we normalize them
|
|
5
|
-
here so upstream code just says "ask <peer> this prompt, save to <path>".
|
|
6
|
-
"""
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import subprocess
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Optional
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
DEFAULT_TIMEOUT_SECONDS = 600 # 10 min per peer call — generous for cold research
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@dataclass(frozen=True)
|
|
19
|
-
class PeerRunResult:
|
|
20
|
-
peer_id: str
|
|
21
|
-
cli: str
|
|
22
|
-
ok: bool
|
|
23
|
-
stdout: str
|
|
24
|
-
stderr: str
|
|
25
|
-
duration_s: float
|
|
26
|
-
command: list[str]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _cli_command(cli: str, prompt: str, model: Optional[str]) -> list[str]:
|
|
30
|
-
"""Return the subprocess argv to invoke `cli` non-interactively with `prompt`."""
|
|
31
|
-
if cli == "claude":
|
|
32
|
-
# Claude Code: -p is non-interactive (print) mode
|
|
33
|
-
cmd = ["claude", "-p", prompt]
|
|
34
|
-
if model:
|
|
35
|
-
cmd.extend(["--model", model])
|
|
36
|
-
return cmd
|
|
37
|
-
if cli == "codex":
|
|
38
|
-
# Codex CLI: `codex exec` is non-interactive; --dangerously-bypass needed to
|
|
39
|
-
# write files / skip approvals when running headless.
|
|
40
|
-
cmd = ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", prompt]
|
|
41
|
-
if model:
|
|
42
|
-
cmd.extend(["--model", model])
|
|
43
|
-
return cmd
|
|
44
|
-
if cli == "gemini":
|
|
45
|
-
cmd = ["gemini", "-p", prompt]
|
|
46
|
-
if model:
|
|
47
|
-
cmd.extend(["--model", model])
|
|
48
|
-
return cmd
|
|
49
|
-
if cli == "ollama":
|
|
50
|
-
# Ollama needs a model name
|
|
51
|
-
return ["ollama", "run", model or "llama3.1", prompt]
|
|
52
|
-
# Unknown CLI — best-effort: pass prompt as last arg
|
|
53
|
-
return [cli, prompt]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def run_peer(
|
|
57
|
-
peer_id: str,
|
|
58
|
-
cli: str,
|
|
59
|
-
prompt: str,
|
|
60
|
-
output_path: Path,
|
|
61
|
-
model: Optional[str] = None,
|
|
62
|
-
timeout_s: int = DEFAULT_TIMEOUT_SECONDS,
|
|
63
|
-
) -> PeerRunResult:
|
|
64
|
-
"""Run one peer LLM against `prompt`, write its stdout to `output_path`.
|
|
65
|
-
|
|
66
|
-
Returns a PeerRunResult capturing success/failure + full stdout + stderr.
|
|
67
|
-
The output file is written regardless of success (empty or error-captured
|
|
68
|
-
text) so that every invocation leaves a file-trace on disk.
|
|
69
|
-
"""
|
|
70
|
-
import time
|
|
71
|
-
|
|
72
|
-
cmd = _cli_command(cli, prompt, model)
|
|
73
|
-
start = time.monotonic()
|
|
74
|
-
try:
|
|
75
|
-
proc = subprocess.run(
|
|
76
|
-
cmd,
|
|
77
|
-
capture_output=True,
|
|
78
|
-
text=True,
|
|
79
|
-
timeout=timeout_s,
|
|
80
|
-
check=False,
|
|
81
|
-
)
|
|
82
|
-
duration = time.monotonic() - start
|
|
83
|
-
ok = proc.returncode == 0
|
|
84
|
-
stdout, stderr = proc.stdout, proc.stderr
|
|
85
|
-
except FileNotFoundError:
|
|
86
|
-
duration = time.monotonic() - start
|
|
87
|
-
ok = False
|
|
88
|
-
stdout = ""
|
|
89
|
-
stderr = f"{cli} binary not found on PATH"
|
|
90
|
-
except subprocess.TimeoutExpired:
|
|
91
|
-
duration = time.monotonic() - start
|
|
92
|
-
ok = False
|
|
93
|
-
stdout = ""
|
|
94
|
-
stderr = f"{cli} timed out after {timeout_s}s"
|
|
95
|
-
|
|
96
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
-
header = (
|
|
98
|
-
f"<!-- peer_id: {peer_id} -->\n"
|
|
99
|
-
f"<!-- cli: {cli} -->\n"
|
|
100
|
-
f"<!-- model: {model or '(default)'} -->\n"
|
|
101
|
-
f"<!-- duration_s: {duration:.1f} -->\n"
|
|
102
|
-
f"<!-- ok: {ok} -->\n\n"
|
|
103
|
-
)
|
|
104
|
-
body = stdout if ok else f"# Peer run FAILED\n\n```\n{stderr}\n```\n\n{stdout}"
|
|
105
|
-
output_path.write_text(header + body, encoding="utf-8")
|
|
106
|
-
return PeerRunResult(
|
|
107
|
-
peer_id=peer_id,
|
|
108
|
-
cli=cli,
|
|
109
|
-
ok=ok,
|
|
110
|
-
stdout=stdout,
|
|
111
|
-
stderr=stderr,
|
|
112
|
-
duration_s=duration,
|
|
113
|
-
command=cmd,
|
|
114
|
-
)
|
|
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
|