paircode 0.7.1__tar.gz → 0.8.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.8.1}/PKG-INFO +17 -6
- {paircode-0.7.1 → paircode-0.8.1}/README.md +12 -2
- {paircode-0.7.1 → paircode-0.8.1}/pyproject.toml +5 -4
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/__init__.py +1 -1
- paircode-0.8.1/src/paircode/installer.py +183 -0
- paircode-0.8.1/src/paircode/runner.py +122 -0
- paircode-0.8.1/tests/test_cli_smoke.py +252 -0
- {paircode-0.7.1 → paircode-0.8.1}/tests/test_smoke.py +48 -1
- 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.8.1}/.gitignore +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/LICENSE +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/__main__.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/cli.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/detect.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/drive.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/gates.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/handshake.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/journey.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/seal.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/state.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/claude_slash_command.md +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/codex_rules.md +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/peers.yaml +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/tests/__init__.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/tests/test_drive.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/tests/test_gates.py +0 -0
- {paircode-0.7.1 → paircode-0.8.1}/tests/test_seal.py +0 -0
- {paircode-0.7.1 → paircode-0.8.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.8.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,20 @@ 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 Claude
|
|
39
|
+
paircode install # registers /paircode in Claude Code (global slash command)
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
After install, open Claude Code in any project and `/paircode` is available.
|
|
43
|
+
|
|
44
|
+
> **Note**: codex and gemini don't have a slash-command registration system
|
|
45
|
+
> we can target, so `paircode install` is a no-op for them. From codex or
|
|
46
|
+
> gemini, invoke paircode from your shell: `paircode status`, `paircode drive "..."`
|
|
47
|
+
> etc. (or wrapped: `codex exec 'paircode status'`).
|
|
48
|
+
|
|
49
|
+
As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
|
|
50
|
+
the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
|
|
51
|
+
logic. paircode adds the peer-review orchestration on top (file-traces, stages,
|
|
52
|
+
gates, journey).
|
|
42
53
|
|
|
43
54
|
## Use it — three entry points
|
|
44
55
|
|
|
@@ -8,10 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
pipx install paircode # or: pip install --user paircode
|
|
11
|
-
paircode install # registers /paircode in Claude
|
|
11
|
+
paircode install # registers /paircode in Claude Code (global slash command)
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
After install, open Claude Code in any project and `/paircode` is available.
|
|
15
|
+
|
|
16
|
+
> **Note**: codex and gemini don't have a slash-command registration system
|
|
17
|
+
> we can target, so `paircode install` is a no-op for them. From codex or
|
|
18
|
+
> gemini, invoke paircode from your shell: `paircode status`, `paircode drive "..."`
|
|
19
|
+
> etc. (or wrapped: `codex exec 'paircode status'`).
|
|
20
|
+
|
|
21
|
+
As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
|
|
22
|
+
the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
|
|
23
|
+
logic. paircode adds the peer-review orchestration on top (file-traces, stages,
|
|
24
|
+
gates, journey).
|
|
15
25
|
|
|
16
26
|
## Use it — three entry points
|
|
17
27
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paircode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.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,183 @@
|
|
|
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
|
|
@@ -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,252 @@
|
|
|
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_returns_noop_for_codex_not_installed_file(tmp_path, monkeypatch):
|
|
198
|
+
"""Regression of the 'broken codex rules' bug: installer must never write
|
|
199
|
+
~/.codex/rules/paircode.rules — that file breaks codex's rule loader."""
|
|
200
|
+
fake_home = tmp_path / "home"
|
|
201
|
+
fake_home.mkdir()
|
|
202
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
203
|
+
import importlib
|
|
204
|
+
|
|
205
|
+
import paircode.detect as d
|
|
206
|
+
import paircode.installer as inst
|
|
207
|
+
|
|
208
|
+
importlib.reload(d)
|
|
209
|
+
importlib.reload(inst)
|
|
210
|
+
|
|
211
|
+
monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
|
|
212
|
+
results = inst.install_all()
|
|
213
|
+
|
|
214
|
+
actions = {r.cli_name: r.action for r in results}
|
|
215
|
+
assert actions["codex"] == "noop"
|
|
216
|
+
|
|
217
|
+
# File must NOT exist
|
|
218
|
+
assert not (fake_home / ".codex" / "rules" / "paircode.rules").exists()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Init + status dispatch smoke
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def test_init_creates_paircode_dir(tmp_path, monkeypatch):
|
|
226
|
+
monkeypatch.chdir(tmp_path)
|
|
227
|
+
runner = CliRunner()
|
|
228
|
+
result = runner.invoke(main, ["init"])
|
|
229
|
+
assert result.exit_code == 0, result.output
|
|
230
|
+
assert (tmp_path / ".paircode").exists()
|
|
231
|
+
assert (tmp_path / ".paircode" / "JOURNEY.md").exists()
|
|
232
|
+
assert (tmp_path / ".paircode" / "peers.yaml").exists()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_status_finds_paircode_dir(tmp_path, monkeypatch):
|
|
236
|
+
monkeypatch.chdir(tmp_path)
|
|
237
|
+
runner = CliRunner()
|
|
238
|
+
# First init
|
|
239
|
+
runner.invoke(main, ["init"])
|
|
240
|
+
# Then status should find it and exit cleanly
|
|
241
|
+
result = runner.invoke(main, ["status"])
|
|
242
|
+
assert result.exit_code == 0
|
|
243
|
+
assert ".paircode" in result.output
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_status_without_paircode_dir_prints_hint(tmp_path, monkeypatch):
|
|
247
|
+
monkeypatch.chdir(tmp_path)
|
|
248
|
+
runner = CliRunner()
|
|
249
|
+
result = runner.invoke(main, ["status"])
|
|
250
|
+
# Should not crash; should suggest init
|
|
251
|
+
assert result.exit_code == 0
|
|
252
|
+
assert "init" in result.output.lower()
|
|
@@ -76,9 +76,56 @@ 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
|
+
# claude gets a real install (writes slash command file)
|
|
79
80
|
assert actions["claude"] == "installed"
|
|
80
|
-
#
|
|
81
|
+
# codex and gemini are intentional no-ops as of v0.8 — we stopped writing
|
|
82
|
+
# broken files to ~/.codex/rules/. Both should report "noop" not "installed".
|
|
83
|
+
assert actions["codex"] == "noop", (
|
|
84
|
+
f"codex should be noop (not installed), got {actions['codex']!r}"
|
|
85
|
+
)
|
|
86
|
+
assert actions["gemini"] == "noop", (
|
|
87
|
+
f"gemini should be noop (not installed), got {actions['gemini']!r}"
|
|
88
|
+
)
|
|
89
|
+
# Verify claude slash command actually landed on disk
|
|
81
90
|
claude_cmd = fake_home / ".claude" / "commands" / "paircode.md"
|
|
82
91
|
assert claude_cmd.exists()
|
|
83
92
|
content = claude_cmd.read_text()
|
|
84
93
|
assert "paircode" in content
|
|
94
|
+
# And verify we do NOT write a broken codex rules file
|
|
95
|
+
codex_rules = fake_home / ".codex" / "rules" / "paircode.rules"
|
|
96
|
+
assert not codex_rules.exists(), (
|
|
97
|
+
f"paircode should not write {codex_rules} — that file breaks codex's rule loader"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_install_cleans_legacy_codex_rules_file(tmp_path, monkeypatch):
|
|
102
|
+
"""Users who installed paircode 0.1–0.7 have a broken ~/.codex/rules/paircode.rules.
|
|
103
|
+
Running install again (or any time) must clean it up."""
|
|
104
|
+
fake_home = tmp_path / "home"
|
|
105
|
+
fake_home.mkdir()
|
|
106
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
107
|
+
import importlib
|
|
108
|
+
|
|
109
|
+
import paircode.detect as d
|
|
110
|
+
import paircode.installer as inst
|
|
111
|
+
|
|
112
|
+
importlib.reload(d)
|
|
113
|
+
importlib.reload(inst)
|
|
114
|
+
|
|
115
|
+
# Seed the legacy broken file
|
|
116
|
+
legacy = fake_home / ".codex" / "rules" / "paircode.rules"
|
|
117
|
+
legacy.parent.mkdir(parents=True)
|
|
118
|
+
legacy.write_text("# old broken markdown-in-starlark\n")
|
|
119
|
+
assert legacy.exists()
|
|
120
|
+
|
|
121
|
+
def fake_which(binary):
|
|
122
|
+
return f"/fake/bin/{binary}"
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr("shutil.which", fake_which)
|
|
125
|
+
inst.install_all()
|
|
126
|
+
|
|
127
|
+
# Legacy file should be gone after install_all
|
|
128
|
+
assert not legacy.exists(), (
|
|
129
|
+
"install_all must remove the legacy ~/.codex/rules/paircode.rules "
|
|
130
|
+
"(it's Starlark-parsed and our markdown broke codex)"
|
|
131
|
+
)
|
|
@@ -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
|