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.
Files changed (33) hide show
  1. {paircode-0.8.1 → paircode-0.9.1}/PKG-INFO +10 -7
  2. {paircode-0.8.1 → paircode-0.9.1}/README.md +9 -6
  3. {paircode-0.8.1 → paircode-0.9.1}/pyproject.toml +1 -1
  4. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/__init__.py +1 -1
  5. paircode-0.9.1/src/paircode/installer.py +188 -0
  6. paircode-0.9.1/src/paircode/templates/codex_slash_command.md +24 -0
  7. paircode-0.9.1/src/paircode/templates/gemini_slash_command.toml +25 -0
  8. {paircode-0.8.1 → paircode-0.9.1}/tests/test_cli_smoke.py +34 -6
  9. {paircode-0.8.1 → paircode-0.9.1}/tests/test_smoke.py +23 -14
  10. paircode-0.8.1/src/paircode/installer.py +0 -183
  11. {paircode-0.8.1 → paircode-0.9.1}/.gitignore +0 -0
  12. {paircode-0.8.1 → paircode-0.9.1}/LICENSE +0 -0
  13. {paircode-0.8.1 → paircode-0.9.1}/diary/001-step-a-architecture.md +0 -0
  14. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/__main__.py +0 -0
  15. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/cli.py +0 -0
  16. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/detect.py +0 -0
  17. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/drive.py +0 -0
  18. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/gates.py +0 -0
  19. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/handshake.py +0 -0
  20. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/journey.py +0 -0
  21. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/runner.py +0 -0
  22. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/seal.py +0 -0
  23. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/state.py +0 -0
  24. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/FOCUS.md +0 -0
  25. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/JOURNEY.md +0 -0
  26. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/claude_slash_command.md +0 -0
  27. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/codex_rules.md +0 -0
  28. {paircode-0.8.1 → paircode-0.9.1}/src/paircode/templates/peers.yaml +0 -0
  29. {paircode-0.8.1 → paircode-0.9.1}/tests/__init__.py +0 -0
  30. {paircode-0.8.1 → paircode-0.9.1}/tests/test_drive.py +0 -0
  31. {paircode-0.8.1 → paircode-0.9.1}/tests/test_gates.py +0 -0
  32. {paircode-0.8.1 → paircode-0.9.1}/tests/test_seal.py +0 -0
  33. {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.8.1
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 Claude Code (global slash command)
39
+ paircode install # registers /paircode in every detected LLM CLI
40
40
  ```
41
41
 
42
- After install, open Claude Code in any project and `/paircode` is available.
42
+ After install, `/paircode` is available in all three:
43
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'`).
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 Claude Code (global slash command)
11
+ paircode install # registers /paircode in every detected LLM CLI
12
12
  ```
13
13
 
14
- After install, open Claude Code in any project and `/paircode` is available.
14
+ After install, `/paircode` is available in all three:
15
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'`).
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.8.1"
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" }
@@ -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.8.0"
5
+ __version__ = "0.9.0"
@@ -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 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."""
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"] == "noop"
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
- # claude gets a real install (writes slash command file)
79
+ # v0.9: all three CLIs get a real slash command file drop.
80
80
  assert actions["claude"] == "installed"
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
+ 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
- content = claude_cmd.read_text()
93
- assert "paircode" in content
94
- # And verify we do NOT write a broken codex rules file
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 should not write {codex_rules} — that file breaks codex's rule loader"
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