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.
Files changed (32) hide show
  1. {paircode-0.7.1 → paircode-0.8.1}/PKG-INFO +17 -6
  2. {paircode-0.7.1 → paircode-0.8.1}/README.md +12 -2
  3. {paircode-0.7.1 → paircode-0.8.1}/pyproject.toml +5 -4
  4. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/__init__.py +1 -1
  5. paircode-0.8.1/src/paircode/installer.py +183 -0
  6. paircode-0.8.1/src/paircode/runner.py +122 -0
  7. paircode-0.8.1/tests/test_cli_smoke.py +252 -0
  8. {paircode-0.7.1 → paircode-0.8.1}/tests/test_smoke.py +48 -1
  9. paircode-0.7.1/src/paircode/installer.py +0 -161
  10. paircode-0.7.1/src/paircode/runner.py +0 -114
  11. {paircode-0.7.1 → paircode-0.8.1}/.gitignore +0 -0
  12. {paircode-0.7.1 → paircode-0.8.1}/LICENSE +0 -0
  13. {paircode-0.7.1 → paircode-0.8.1}/diary/001-step-a-architecture.md +0 -0
  14. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/__main__.py +0 -0
  15. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/cli.py +0 -0
  16. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/detect.py +0 -0
  17. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/drive.py +0 -0
  18. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/gates.py +0 -0
  19. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/handshake.py +0 -0
  20. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/journey.py +0 -0
  21. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/seal.py +0 -0
  22. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/state.py +0 -0
  23. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/FOCUS.md +0 -0
  24. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/JOURNEY.md +0 -0
  25. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/claude_slash_command.md +0 -0
  26. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/codex_rules.md +0 -0
  27. {paircode-0.7.1 → paircode-0.8.1}/src/paircode/templates/peers.yaml +0 -0
  28. {paircode-0.7.1 → paircode-0.8.1}/tests/__init__.py +0 -0
  29. {paircode-0.7.1 → paircode-0.8.1}/tests/test_drive.py +0 -0
  30. {paircode-0.7.1 → paircode-0.8.1}/tests/test_gates.py +0 -0
  31. {paircode-0.7.1 → paircode-0.8.1}/tests/test_seal.py +0 -0
  32. {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.7.1
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/tsomerville2/paircode
6
- Project-URL: Repository, https://github.com/tsomerville2/paircode
7
- Project-URL: Issues, https://github.com/tsomerville2/paircode/issues
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 / Codex / Gemini
39
+ paircode install # registers /paircode in Claude Code (global slash command)
39
40
  ```
40
41
 
41
- Then open Claude Code, Codex, or Gemini and you'll see `/paircode` available globally.
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 / Codex / Gemini
11
+ paircode install # registers /paircode in Claude Code (global slash command)
12
12
  ```
13
13
 
14
- Then open Claude Code, Codex, or Gemini and you'll see `/paircode` available globally.
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.1"
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/tsomerville2/paircode"
46
- Repository = "https://github.com/tsomerville2/paircode"
47
- Issues = "https://github.com/tsomerville2/paircode/issues"
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"
@@ -2,4 +2,4 @@
2
2
 
3
3
  See README.md and diary/001-step-a-architecture.md for the full design.
4
4
  """
5
- __version__ = "0.7.0"
5
+ __version__ = "0.8.0"
@@ -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
- # Verify file landed
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