paircode 0.8.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.8.1 → paircode-0.9.1}/PKG-INFO +10 -7
- {paircode-0.8.1 → paircode-0.9.1}/README.md +9 -6
- {paircode-0.8.1 → paircode-0.9.1}/pyproject.toml +1 -1
- {paircode-0.8.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/templates/codex_slash_command.md +24 -0
- paircode-0.9.1/src/paircode/templates/gemini_slash_command.toml +25 -0
- {paircode-0.8.1 → paircode-0.9.1}/tests/test_cli_smoke.py +34 -6
- {paircode-0.8.1 → paircode-0.9.1}/tests/test_smoke.py +23 -14
- paircode-0.8.1/src/paircode/installer.py +0 -183
- {paircode-0.8.1 → paircode-0.9.1}/.gitignore +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/LICENSE +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/__main__.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/cli.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/detect.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/drive.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/gates.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/handshake.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/journey.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/runner.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/seal.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/state.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/claude_slash_command.md +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/codex_rules.md +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/peers.yaml +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/tests/__init__.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/tests/test_drive.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/tests/test_gates.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/tests/test_seal.py +0 -0
- {paircode-0.8.1 → paircode-0.9.1}/tests/test_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paircode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review.
|
|
5
5
|
Project-URL: Homepage, https://github.com/starshipagentic/paircode
|
|
6
6
|
Project-URL: Repository, https://github.com/starshipagentic/paircode
|
|
@@ -36,15 +36,18 @@ Description-Content-Type: text/markdown
|
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
pipx install paircode # or: pip install --user paircode
|
|
39
|
-
paircode install # registers /paircode in
|
|
39
|
+
paircode install # registers /paircode in every detected LLM CLI
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
After install,
|
|
42
|
+
After install, `/paircode` is available in all three:
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
| CLI | File installed |
|
|
45
|
+
|---|---|
|
|
46
|
+
| Claude Code | `~/.claude/commands/paircode.md` |
|
|
47
|
+
| Codex CLI | `~/.codex/prompts/paircode.md` |
|
|
48
|
+
| Gemini CLI | `~/.gemini/commands/paircode.toml` |
|
|
49
|
+
|
|
50
|
+
Open any of them and type `/paircode`. In Gemini, you may need `/commands reload` the first time.
|
|
48
51
|
|
|
49
52
|
As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
|
|
50
53
|
the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
|
|
@@ -8,15 +8,18 @@
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
pipx install paircode # or: pip install --user paircode
|
|
11
|
-
paircode install # registers /paircode in
|
|
11
|
+
paircode install # registers /paircode in every detected LLM CLI
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
After install,
|
|
14
|
+
After install, `/paircode` is available in all three:
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
| CLI | File installed |
|
|
17
|
+
|---|---|
|
|
18
|
+
| Claude Code | `~/.claude/commands/paircode.md` |
|
|
19
|
+
| Codex CLI | `~/.codex/prompts/paircode.md` |
|
|
20
|
+
| Gemini CLI | `~/.gemini/commands/paircode.toml` |
|
|
21
|
+
|
|
22
|
+
Open any of them and type `/paircode`. In Gemini, you may need `/commands reload` the first time.
|
|
20
23
|
|
|
21
24
|
As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
|
|
22
25
|
the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paircode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.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" }
|
|
@@ -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,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
|
+
"""
|
|
@@ -194,9 +194,10 @@ def test_run_peer_fast_kwarg_passes_through(monkeypatch, tmp_path):
|
|
|
194
194
|
# Installer: codex no-op + legacy cleanup
|
|
195
195
|
# ---------------------------------------------------------------------------
|
|
196
196
|
|
|
197
|
-
def
|
|
198
|
-
"""Regression of the 'broken codex rules' bug: installer must
|
|
199
|
-
~/.codex/
|
|
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)."""
|
|
200
201
|
fake_home = tmp_path / "home"
|
|
201
202
|
fake_home.mkdir()
|
|
202
203
|
monkeypatch.setenv("HOME", str(fake_home))
|
|
@@ -212,12 +213,39 @@ def test_installer_returns_noop_for_codex_not_installed_file(tmp_path, monkeypat
|
|
|
212
213
|
results = inst.install_all()
|
|
213
214
|
|
|
214
215
|
actions = {r.cli_name: r.action for r in results}
|
|
215
|
-
assert actions["codex"] == "
|
|
216
|
-
|
|
217
|
-
# File must NOT exist
|
|
216
|
+
assert actions["codex"] == "installed"
|
|
217
|
+
assert (fake_home / ".codex" / "prompts" / "paircode.md").exists()
|
|
218
218
|
assert not (fake_home / ".codex" / "rules" / "paircode.rules").exists()
|
|
219
219
|
|
|
220
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
|
+
|
|
221
249
|
# ---------------------------------------------------------------------------
|
|
222
250
|
# Init + status dispatch smoke
|
|
223
251
|
# ---------------------------------------------------------------------------
|
|
@@ -76,25 +76,34 @@ 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
|
-
#
|
|
79
|
+
# v0.9: all three CLIs get a real slash command file drop.
|
|
80
80
|
assert actions["claude"] == "installed"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
86
|
-
assert actions["gemini"] == "noop", (
|
|
87
|
-
f"gemini should be noop (not installed), got {actions['gemini']!r}"
|
|
88
|
-
)
|
|
89
|
-
# Verify claude slash command actually landed on disk
|
|
81
|
+
assert actions["codex"] == "installed"
|
|
82
|
+
assert actions["gemini"] == "installed"
|
|
83
|
+
|
|
84
|
+
# Correct paths per CLI
|
|
90
85
|
claude_cmd = fake_home / ".claude" / "commands" / "paircode.md"
|
|
91
86
|
assert claude_cmd.exists()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
95
104
|
codex_rules = fake_home / ".codex" / "rules" / "paircode.rules"
|
|
96
105
|
assert not codex_rules.exists(), (
|
|
97
|
-
f"paircode
|
|
106
|
+
f"paircode must never write {codex_rules} — that file breaks codex's rule loader"
|
|
98
107
|
)
|
|
99
108
|
|
|
100
109
|
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
"""Install paircode's slash command into detected LLM CLIs.
|
|
2
|
-
|
|
3
|
-
Strategy per CLI:
|
|
4
|
-
- Claude Code: writes ~/.claude/commands/paircode.md as a real slash command.
|
|
5
|
-
When the user types `/paircode` in any Claude Code session, this file
|
|
6
|
-
tells Claude to invoke the paircode CLI via bash.
|
|
7
|
-
- Codex: no file written. Codex doesn't have a user-facing slash-command
|
|
8
|
-
system. Users invoke `paircode` from their shell or `codex exec paircode ...`.
|
|
9
|
-
Previous versions wrote `~/.codex/rules/paircode.rules` — that file is
|
|
10
|
-
Starlark-formatted and our markdown content broke codex's rule loader.
|
|
11
|
-
`uninstall` still cleans up that legacy path.
|
|
12
|
-
- Gemini: no file written. Same reasoning — gemini's custom-command story
|
|
13
|
-
is `gemini skills install` with a git-repo source, not a markdown drop.
|
|
14
|
-
That's a v0.9+ stretch goal. For now, gemini users invoke paircode from
|
|
15
|
-
the shell.
|
|
16
|
-
"""
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
from dataclasses import dataclass
|
|
20
|
-
from importlib import resources
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
from paircode.detect import detect_all, CliInfo
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@dataclass(frozen=True)
|
|
27
|
-
class InstallResult:
|
|
28
|
-
cli_name: str
|
|
29
|
-
action: str # "installed", "skipped", "failed", "noop"
|
|
30
|
-
path: Path | None
|
|
31
|
-
message: str
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _read_template(name: str) -> str:
|
|
35
|
-
return resources.files("paircode.templates").joinpath(name).read_text(encoding="utf-8")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# ---------------------------------------------------------------------------
|
|
39
|
-
# Per-CLI installers
|
|
40
|
-
# ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
def install_claude(info: CliInfo) -> InstallResult:
|
|
43
|
-
"""Write ~/.claude/commands/paircode.md — a real Claude Code slash command."""
|
|
44
|
-
if not info.installed:
|
|
45
|
-
return InstallResult(
|
|
46
|
-
cli_name="claude",
|
|
47
|
-
action="skipped",
|
|
48
|
-
path=None,
|
|
49
|
-
message=f"claude CLI not on PATH. {info.install_hint}",
|
|
50
|
-
)
|
|
51
|
-
commands_dir = info.config_dir / "commands"
|
|
52
|
-
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
-
target = commands_dir / "paircode.md"
|
|
54
|
-
template = _read_template("claude_slash_command.md")
|
|
55
|
-
target.write_text(template, encoding="utf-8")
|
|
56
|
-
return InstallResult(
|
|
57
|
-
cli_name="claude",
|
|
58
|
-
action="installed",
|
|
59
|
-
path=target,
|
|
60
|
-
message=f"Wrote /paircode slash command to {target}. Use it from any Claude Code session.",
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def install_codex(info: CliInfo) -> InstallResult:
|
|
65
|
-
"""Codex has no user-facing slash-command system. No-op, print guidance."""
|
|
66
|
-
if not info.installed:
|
|
67
|
-
return InstallResult(
|
|
68
|
-
cli_name="codex",
|
|
69
|
-
action="skipped",
|
|
70
|
-
path=None,
|
|
71
|
-
message=f"codex CLI not on PATH. {info.install_hint}",
|
|
72
|
-
)
|
|
73
|
-
return InstallResult(
|
|
74
|
-
cli_name="codex",
|
|
75
|
-
action="noop",
|
|
76
|
-
path=None,
|
|
77
|
-
message=(
|
|
78
|
-
"codex has no slash-command system — nothing to install. "
|
|
79
|
-
"Invoke paircode from your shell (`paircode status`) or via "
|
|
80
|
-
"`codex exec 'paircode status'` from inside a codex session."
|
|
81
|
-
),
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def install_gemini(info: CliInfo) -> InstallResult:
|
|
86
|
-
"""Gemini's custom-command story uses `gemini skills install` with a git
|
|
87
|
-
repo. That's not an easy installer drop — deferred to v0.9+. No-op."""
|
|
88
|
-
if not info.installed:
|
|
89
|
-
return InstallResult(
|
|
90
|
-
cli_name="gemini",
|
|
91
|
-
action="skipped",
|
|
92
|
-
path=None,
|
|
93
|
-
message=f"gemini CLI not on PATH. {info.install_hint}",
|
|
94
|
-
)
|
|
95
|
-
return InstallResult(
|
|
96
|
-
cli_name="gemini",
|
|
97
|
-
action="noop",
|
|
98
|
-
path=None,
|
|
99
|
-
message=(
|
|
100
|
-
"gemini uses `gemini skills install` for custom commands — "
|
|
101
|
-
"proper skill-registration lands in v0.9+. For now, invoke "
|
|
102
|
-
"paircode from your shell or via `gemini -p 'run paircode status'`."
|
|
103
|
-
),
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# ---------------------------------------------------------------------------
|
|
108
|
-
# Public entry points
|
|
109
|
-
# ---------------------------------------------------------------------------
|
|
110
|
-
|
|
111
|
-
def install_all() -> list[InstallResult]:
|
|
112
|
-
"""Run every CLI's installer. Also cleans up legacy broken files first."""
|
|
113
|
-
_clean_legacy_broken_files()
|
|
114
|
-
detected = detect_all()
|
|
115
|
-
installers = {
|
|
116
|
-
"claude": install_claude,
|
|
117
|
-
"codex": install_codex,
|
|
118
|
-
"gemini": install_gemini,
|
|
119
|
-
}
|
|
120
|
-
results: list[InstallResult] = []
|
|
121
|
-
for name, info in detected.items():
|
|
122
|
-
installer = installers.get(name)
|
|
123
|
-
if not installer:
|
|
124
|
-
continue
|
|
125
|
-
try:
|
|
126
|
-
results.append(installer(info))
|
|
127
|
-
except Exception as exc: # pragma: no cover — defensive
|
|
128
|
-
results.append(
|
|
129
|
-
InstallResult(
|
|
130
|
-
cli_name=name, action="failed", path=None,
|
|
131
|
-
message=f"{type(exc).__name__}: {exc}",
|
|
132
|
-
)
|
|
133
|
-
)
|
|
134
|
-
return results
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _clean_legacy_broken_files() -> None:
|
|
138
|
-
"""Remove files that previous versions of paircode installed and which we
|
|
139
|
-
now know to be broken or misplaced. Silent; best-effort."""
|
|
140
|
-
for legacy_path in (
|
|
141
|
-
# v0.1–v0.7 wrote markdown to codex's Starlark rules dir, which broke
|
|
142
|
-
# codex's rule loader. Always clean this up on any install.
|
|
143
|
-
Path.home() / ".codex" / "rules" / "paircode.rules",
|
|
144
|
-
# v0.1–v0.7 also dropped a reference file at ~/.gemini/paircode.md.
|
|
145
|
-
# Harmless but no longer created; remove to keep the dir clean.
|
|
146
|
-
Path.home() / ".gemini" / "paircode.md",
|
|
147
|
-
):
|
|
148
|
-
try:
|
|
149
|
-
if legacy_path.exists():
|
|
150
|
-
legacy_path.unlink()
|
|
151
|
-
except OSError:
|
|
152
|
-
pass
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def uninstall_all() -> list[InstallResult]:
|
|
156
|
-
"""Remove paircode entries from every CLI config dir (idempotent).
|
|
157
|
-
Also cleans up legacy broken paths from prior paircode versions."""
|
|
158
|
-
results: list[InstallResult] = []
|
|
159
|
-
paths: list[tuple[Path, str]] = [
|
|
160
|
-
(Path.home() / ".claude" / "commands" / "paircode.md", "claude"),
|
|
161
|
-
# Legacy paths — older paircode put these here, we still clean them up
|
|
162
|
-
(Path.home() / ".codex" / "rules" / "paircode.rules", "codex"),
|
|
163
|
-
(Path.home() / ".gemini" / "paircode.md", "gemini"),
|
|
164
|
-
]
|
|
165
|
-
for path, cli_name in paths:
|
|
166
|
-
if path.exists():
|
|
167
|
-
try:
|
|
168
|
-
path.unlink()
|
|
169
|
-
results.append(InstallResult(
|
|
170
|
-
cli_name=cli_name, action="installed", # "removed"
|
|
171
|
-
path=path, message=f"Removed {path}",
|
|
172
|
-
))
|
|
173
|
-
except OSError as exc:
|
|
174
|
-
results.append(InstallResult(
|
|
175
|
-
cli_name=cli_name, action="failed",
|
|
176
|
-
path=path, message=f"Failed to remove {path}: {exc}",
|
|
177
|
-
))
|
|
178
|
-
else:
|
|
179
|
-
results.append(InstallResult(
|
|
180
|
-
cli_name=cli_name, action="skipped",
|
|
181
|
-
path=path, message=f"Nothing to remove at {path}",
|
|
182
|
-
))
|
|
183
|
-
return results
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|