paircode 0.9.1__tar.gz → 0.10.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.9.1 → paircode-0.10.1}/PKG-INFO +2 -2
- {paircode-0.9.1 → paircode-0.10.1}/pyproject.toml +2 -2
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/__init__.py +1 -1
- paircode-0.10.1/src/paircode/installer.py +322 -0
- {paircode-0.9.1 → paircode-0.10.1}/tests/test_cli_smoke.py +115 -18
- {paircode-0.9.1 → paircode-0.10.1}/tests/test_smoke.py +45 -25
- paircode-0.9.1/src/paircode/installer.py +0 -188
- {paircode-0.9.1 → paircode-0.10.1}/.gitignore +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/LICENSE +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/README.md +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/__main__.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/cli.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/detect.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/drive.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/gates.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/handshake.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/journey.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/runner.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/seal.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/state.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/claude_slash_command.md +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/codex_rules.md +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/codex_slash_command.md +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/gemini_slash_command.toml +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/src/paircode/templates/peers.yaml +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/tests/__init__.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/tests/test_drive.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/tests/test_gates.py +0 -0
- {paircode-0.9.1 → paircode-0.10.1}/tests/test_seal.py +0 -0
- {paircode-0.9.1 → paircode-0.10.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.10.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
|
|
@@ -21,7 +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.
|
|
24
|
+
Requires-Dist: cliworker>=0.8.1
|
|
25
25
|
Requires-Dist: pyyaml>=6.0
|
|
26
26
|
Requires-Dist: rich>=13.0
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paircode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.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,7 +39,7 @@ dependencies = [
|
|
|
39
39
|
"click>=8.1",
|
|
40
40
|
"pyyaml>=6.0",
|
|
41
41
|
"rich>=13.0",
|
|
42
|
-
"cliworker>=0.
|
|
42
|
+
"cliworker>=0.8.1",
|
|
43
43
|
]
|
|
44
44
|
|
|
45
45
|
[project.urls]
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Install paircode's slash command into detected LLM CLIs.
|
|
2
|
+
|
|
3
|
+
Strategy per CLI:
|
|
4
|
+
- Claude Code: file-drop ~/.claude/commands/paircode.md
|
|
5
|
+
Claude's native plugin-install requires publishing a marketplace;
|
|
6
|
+
overkill for one command. File-drop is first-class + documented.
|
|
7
|
+
- Codex CLI: native register via `codex marketplace add starshipagentic/paircode-codex`
|
|
8
|
+
(git-clones the satellite repo into ~/.codex/.tmp/marketplaces/).
|
|
9
|
+
Satellite contains .agents/plugins/marketplace.json + plugin manifest +
|
|
10
|
+
commands/paircode.md. Replaces the v0.9 file-drop at ~/.codex/prompts/.
|
|
11
|
+
- Gemini CLI: native register via `gemini extensions install https://github.com/starshipagentic/paircode-gemini --consent`
|
|
12
|
+
Git-clones the satellite repo into ~/.gemini/extensions/paircode/.
|
|
13
|
+
Satellite contains gemini-extension.json + commands/paircode.toml.
|
|
14
|
+
Replaces the v0.9 file-drop at ~/.gemini/commands/.
|
|
15
|
+
|
|
16
|
+
Native-register subprocess calls go through `cliworker.invoke()` — no LLM semantics,
|
|
17
|
+
stdin=DEVNULL for fail-fast on unexpected prompts, capture stderr for fallback
|
|
18
|
+
messaging.
|
|
19
|
+
|
|
20
|
+
Also cleans up legacy files from pre-v0.10 paircode versions on every install.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from importlib import resources
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from cliworker import invoke
|
|
29
|
+
|
|
30
|
+
from paircode.detect import detect_all, CliInfo
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Satellite repo coordinates — update here when repos move.
|
|
34
|
+
CODEX_MARKETPLACE = "starshipagentic/paircode-codex"
|
|
35
|
+
GEMINI_EXTENSION_URL = "https://github.com/starshipagentic/paircode-gemini"
|
|
36
|
+
|
|
37
|
+
# Per-CLI timeouts for the native install commands (git clones + setup).
|
|
38
|
+
NATIVE_INSTALL_TIMEOUT_S = 90
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class InstallResult:
|
|
43
|
+
cli_name: str
|
|
44
|
+
action: str # "installed", "skipped", "failed", "noop", "already"
|
|
45
|
+
path: Path | None
|
|
46
|
+
message: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_template(name: str) -> str:
|
|
50
|
+
return resources.files("paircode.templates").joinpath(name).read_text(encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Per-CLI installers
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def install_claude(info: CliInfo) -> InstallResult:
|
|
58
|
+
"""Write ~/.claude/commands/paircode.md — file-drop (native for Claude Code)."""
|
|
59
|
+
if not info.installed:
|
|
60
|
+
return InstallResult(
|
|
61
|
+
cli_name="claude", action="skipped", path=None,
|
|
62
|
+
message=f"claude CLI not on PATH. {info.install_hint}",
|
|
63
|
+
)
|
|
64
|
+
commands_dir = info.config_dir / "commands"
|
|
65
|
+
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
target = commands_dir / "paircode.md"
|
|
67
|
+
target.write_text(_read_template("claude_slash_command.md"), encoding="utf-8")
|
|
68
|
+
return InstallResult(
|
|
69
|
+
cli_name="claude", action="installed", path=target,
|
|
70
|
+
message=f"Wrote /paircode slash command to {target}.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _codex_already_installed() -> bool:
|
|
75
|
+
"""Idempotency check: is the paircode marketplace already registered?"""
|
|
76
|
+
config = Path.home() / ".codex" / "config.toml"
|
|
77
|
+
if not config.exists():
|
|
78
|
+
return False
|
|
79
|
+
try:
|
|
80
|
+
text = config.read_text(encoding="utf-8", errors="ignore")
|
|
81
|
+
except OSError:
|
|
82
|
+
return False
|
|
83
|
+
return "[marketplaces.paircode]" in text or "paircode-codex" in text
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def install_codex(info: CliInfo) -> InstallResult:
|
|
87
|
+
"""Native register via `codex marketplace add starshipagentic/paircode-codex`."""
|
|
88
|
+
if not info.installed:
|
|
89
|
+
return InstallResult(
|
|
90
|
+
cli_name="codex", action="skipped", path=None,
|
|
91
|
+
message=f"codex CLI not on PATH. {info.install_hint}",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if _codex_already_installed():
|
|
95
|
+
return InstallResult(
|
|
96
|
+
cli_name="codex", action="already", path=None,
|
|
97
|
+
message="paircode already registered with codex. Skip.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
result = invoke(
|
|
101
|
+
"codex", "marketplace", "add", CODEX_MARKETPLACE,
|
|
102
|
+
timeout_s=NATIVE_INSTALL_TIMEOUT_S,
|
|
103
|
+
)
|
|
104
|
+
if result.ok:
|
|
105
|
+
return InstallResult(
|
|
106
|
+
cli_name="codex", action="installed", path=None,
|
|
107
|
+
message=f"Ran: codex marketplace add {CODEX_MARKETPLACE}",
|
|
108
|
+
)
|
|
109
|
+
return InstallResult(
|
|
110
|
+
cli_name="codex", action="failed", path=None,
|
|
111
|
+
message=(
|
|
112
|
+
f"codex marketplace add failed. Run yourself:\n"
|
|
113
|
+
f" codex marketplace add {CODEX_MARKETPLACE}\n"
|
|
114
|
+
f"stderr: {result.stderr.strip()[:200]}"
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _gemini_already_installed() -> bool:
|
|
120
|
+
"""Idempotency check: is the paircode extension already installed?"""
|
|
121
|
+
result = invoke("gemini", "extensions", "list", timeout_s=15)
|
|
122
|
+
if not result.ok:
|
|
123
|
+
return False
|
|
124
|
+
return "paircode-gemini" in result.stdout or "paircode (" in result.stdout
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install_gemini(info: CliInfo) -> InstallResult:
|
|
128
|
+
"""Native register via `gemini extensions install <url> --consent`."""
|
|
129
|
+
if not info.installed:
|
|
130
|
+
return InstallResult(
|
|
131
|
+
cli_name="gemini", action="skipped", path=None,
|
|
132
|
+
message=f"gemini CLI not on PATH. {info.install_hint}",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if _gemini_already_installed():
|
|
136
|
+
return InstallResult(
|
|
137
|
+
cli_name="gemini", action="already", path=None,
|
|
138
|
+
message="paircode already installed as gemini extension. Skip.",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
result = invoke(
|
|
142
|
+
"gemini", "extensions", "install", GEMINI_EXTENSION_URL, "--consent",
|
|
143
|
+
timeout_s=NATIVE_INSTALL_TIMEOUT_S,
|
|
144
|
+
)
|
|
145
|
+
if result.ok:
|
|
146
|
+
return InstallResult(
|
|
147
|
+
cli_name="gemini", action="installed", path=None,
|
|
148
|
+
message=(
|
|
149
|
+
f"Ran: gemini extensions install {GEMINI_EXTENSION_URL} --consent. "
|
|
150
|
+
"Inside gemini, run `/commands reload` (or restart) to pick up /paircode."
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
return InstallResult(
|
|
154
|
+
cli_name="gemini", action="failed", path=None,
|
|
155
|
+
message=(
|
|
156
|
+
f"gemini extensions install failed. Run yourself:\n"
|
|
157
|
+
f" gemini extensions install {GEMINI_EXTENSION_URL} --consent\n"
|
|
158
|
+
f"stderr: {result.stderr.strip()[:200]}"
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Public entry points
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def install_all() -> list[InstallResult]:
|
|
168
|
+
"""Run every CLI's installer. Also cleans up legacy paths first."""
|
|
169
|
+
_clean_legacy_paths()
|
|
170
|
+
detected = detect_all()
|
|
171
|
+
installers = {
|
|
172
|
+
"claude": install_claude,
|
|
173
|
+
"codex": install_codex,
|
|
174
|
+
"gemini": install_gemini,
|
|
175
|
+
}
|
|
176
|
+
results: list[InstallResult] = []
|
|
177
|
+
for name, info in detected.items():
|
|
178
|
+
installer = installers.get(name)
|
|
179
|
+
if not installer:
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
results.append(installer(info))
|
|
183
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
184
|
+
results.append(InstallResult(
|
|
185
|
+
cli_name=name, action="failed", path=None,
|
|
186
|
+
message=f"{type(exc).__name__}: {exc}",
|
|
187
|
+
))
|
|
188
|
+
return results
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _clean_legacy_paths() -> None:
|
|
192
|
+
"""Remove files that prior paircode versions installed and which we now
|
|
193
|
+
know to be broken, misplaced, or replaced by native-register flows."""
|
|
194
|
+
legacy_files = (
|
|
195
|
+
# v0.1–v0.7: wrote markdown to codex's Starlark rules dir → broke codex.
|
|
196
|
+
Path.home() / ".codex" / "rules" / "paircode.rules",
|
|
197
|
+
# v0.8: wrote a reference file at ~/.gemini/paircode.md (harmless but obsolete).
|
|
198
|
+
Path.home() / ".gemini" / "paircode.md",
|
|
199
|
+
# v0.9: wrote file-drop slash commands at these paths. v0.10+ uses native register.
|
|
200
|
+
Path.home() / ".codex" / "prompts" / "paircode.md",
|
|
201
|
+
Path.home() / ".gemini" / "commands" / "paircode.toml",
|
|
202
|
+
)
|
|
203
|
+
for path in legacy_files:
|
|
204
|
+
try:
|
|
205
|
+
if path.exists() and path.is_file():
|
|
206
|
+
path.unlink()
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def uninstall_all() -> list[InstallResult]:
|
|
212
|
+
"""Remove paircode from every CLI. Uses native uninstall where available,
|
|
213
|
+
file-drop removal where not."""
|
|
214
|
+
results: list[InstallResult] = []
|
|
215
|
+
|
|
216
|
+
# Claude — file-drop, just remove the file.
|
|
217
|
+
claude_cmd = Path.home() / ".claude" / "commands" / "paircode.md"
|
|
218
|
+
if claude_cmd.exists():
|
|
219
|
+
try:
|
|
220
|
+
claude_cmd.unlink()
|
|
221
|
+
results.append(InstallResult(
|
|
222
|
+
cli_name="claude", action="installed",
|
|
223
|
+
path=claude_cmd, message=f"Removed {claude_cmd}",
|
|
224
|
+
))
|
|
225
|
+
except OSError as exc:
|
|
226
|
+
results.append(InstallResult(
|
|
227
|
+
cli_name="claude", action="failed",
|
|
228
|
+
path=claude_cmd, message=f"Failed to remove {claude_cmd}: {exc}",
|
|
229
|
+
))
|
|
230
|
+
else:
|
|
231
|
+
results.append(InstallResult(
|
|
232
|
+
cli_name="claude", action="skipped",
|
|
233
|
+
path=claude_cmd, message=f"Nothing to remove at {claude_cmd}",
|
|
234
|
+
))
|
|
235
|
+
|
|
236
|
+
# Codex — no native remove command. Strip the marketplace stanza from config.toml
|
|
237
|
+
# and rm -rf the cache dir. Fragile but the only option until codex adds a CLI.
|
|
238
|
+
config = Path.home() / ".codex" / "config.toml"
|
|
239
|
+
cache_dir = Path.home() / ".codex" / ".tmp" / "marketplaces" / "paircode"
|
|
240
|
+
removed_codex = False
|
|
241
|
+
if config.exists():
|
|
242
|
+
try:
|
|
243
|
+
text = config.read_text(encoding="utf-8")
|
|
244
|
+
# Strip the [marketplaces.paircode] section and its key/value lines.
|
|
245
|
+
new_text = _strip_toml_section(text, "marketplaces.paircode")
|
|
246
|
+
new_text = _strip_toml_section(new_text, 'plugins."paircode@paircode"')
|
|
247
|
+
if new_text != text:
|
|
248
|
+
config.write_text(new_text, encoding="utf-8")
|
|
249
|
+
removed_codex = True
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
results.append(InstallResult(
|
|
252
|
+
cli_name="codex", action="failed", path=config,
|
|
253
|
+
message=f"Failed to edit {config}: {exc}",
|
|
254
|
+
))
|
|
255
|
+
if cache_dir.exists():
|
|
256
|
+
import shutil as _sh
|
|
257
|
+
try:
|
|
258
|
+
_sh.rmtree(cache_dir)
|
|
259
|
+
removed_codex = True
|
|
260
|
+
except OSError:
|
|
261
|
+
pass
|
|
262
|
+
if removed_codex:
|
|
263
|
+
results.append(InstallResult(
|
|
264
|
+
cli_name="codex", action="installed", path=None,
|
|
265
|
+
message="Removed paircode marketplace from ~/.codex/config.toml + cache.",
|
|
266
|
+
))
|
|
267
|
+
else:
|
|
268
|
+
results.append(InstallResult(
|
|
269
|
+
cli_name="codex", action="skipped", path=None,
|
|
270
|
+
message="No paircode marketplace registered with codex.",
|
|
271
|
+
))
|
|
272
|
+
|
|
273
|
+
# Gemini — native uninstall command exists.
|
|
274
|
+
result = invoke("gemini", "extensions", "uninstall", "paircode", timeout_s=30)
|
|
275
|
+
if result.ok:
|
|
276
|
+
results.append(InstallResult(
|
|
277
|
+
cli_name="gemini", action="installed", path=None,
|
|
278
|
+
message="Ran: gemini extensions uninstall paircode",
|
|
279
|
+
))
|
|
280
|
+
else:
|
|
281
|
+
# May fail if not installed; treat as skipped when stderr mentions that
|
|
282
|
+
if "not installed" in result.stderr.lower() or "no extension" in result.stderr.lower():
|
|
283
|
+
results.append(InstallResult(
|
|
284
|
+
cli_name="gemini", action="skipped", path=None,
|
|
285
|
+
message="paircode gemini extension not installed; nothing to remove.",
|
|
286
|
+
))
|
|
287
|
+
else:
|
|
288
|
+
results.append(InstallResult(
|
|
289
|
+
cli_name="gemini", action="failed", path=None,
|
|
290
|
+
message=(
|
|
291
|
+
f"gemini extensions uninstall failed. Run yourself:\n"
|
|
292
|
+
f" gemini extensions uninstall paircode\n"
|
|
293
|
+
f"stderr: {result.stderr.strip()[:200]}"
|
|
294
|
+
),
|
|
295
|
+
))
|
|
296
|
+
|
|
297
|
+
# Legacy cleanup (same as install_all)
|
|
298
|
+
_clean_legacy_paths()
|
|
299
|
+
|
|
300
|
+
return results
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _strip_toml_section(text: str, section_name: str) -> str:
|
|
304
|
+
"""Remove `[section_name]` and its following key/value lines from a TOML string.
|
|
305
|
+
|
|
306
|
+
Returns the modified text. A section ends at the next `[...]` header or EOF.
|
|
307
|
+
No-op if the section isn't present.
|
|
308
|
+
"""
|
|
309
|
+
lines = text.splitlines(keepends=True)
|
|
310
|
+
out: list[str] = []
|
|
311
|
+
skipping = False
|
|
312
|
+
target = f"[{section_name}]"
|
|
313
|
+
for line in lines:
|
|
314
|
+
stripped = line.strip()
|
|
315
|
+
if stripped == target:
|
|
316
|
+
skipping = True
|
|
317
|
+
continue
|
|
318
|
+
if skipping and stripped.startswith("[") and stripped.endswith("]"):
|
|
319
|
+
skipping = False # new section started
|
|
320
|
+
if not skipping:
|
|
321
|
+
out.append(line)
|
|
322
|
+
return "".join(out)
|
|
@@ -191,13 +191,29 @@ def test_run_peer_fast_kwarg_passes_through(monkeypatch, tmp_path):
|
|
|
191
191
|
|
|
192
192
|
|
|
193
193
|
# ---------------------------------------------------------------------------
|
|
194
|
-
# Installer: codex
|
|
194
|
+
# Installer: native-register for codex + gemini via cliworker.invoke()
|
|
195
195
|
# ---------------------------------------------------------------------------
|
|
196
196
|
|
|
197
|
-
def
|
|
198
|
-
"""
|
|
199
|
-
|
|
200
|
-
|
|
197
|
+
def _fake_cliworker_invoke_success(captured):
|
|
198
|
+
"""Return a fake invoke that captures calls and always succeeds."""
|
|
199
|
+
from cliworker.core import CLIResult
|
|
200
|
+
from cliworker.registry import CLISpec
|
|
201
|
+
|
|
202
|
+
def fake(cli, *args, **kwargs):
|
|
203
|
+
captured.append((cli, args))
|
|
204
|
+
stdout = ""
|
|
205
|
+
# Simulate empty list output for gemini extensions list (idempotency check)
|
|
206
|
+
return CLIResult(
|
|
207
|
+
spec=CLISpec(cli=cli), ok=True, stdout=stdout, stderr="",
|
|
208
|
+
duration_s=0.01, returncode=0, argv=[cli, *args], skipped_reason=None,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return fake
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_installer_codex_calls_marketplace_add_via_invoke(tmp_path, monkeypatch):
|
|
215
|
+
"""paircode v0.10 delegates codex install to cliworker.invoke()
|
|
216
|
+
running `codex marketplace add starshipagentic/paircode-codex`."""
|
|
201
217
|
fake_home = tmp_path / "home"
|
|
202
218
|
fake_home.mkdir()
|
|
203
219
|
monkeypatch.setenv("HOME", str(fake_home))
|
|
@@ -210,17 +226,25 @@ def test_installer_writes_codex_prompt_never_broken_rules_file(tmp_path, monkeyp
|
|
|
210
226
|
importlib.reload(inst)
|
|
211
227
|
|
|
212
228
|
monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
|
|
213
|
-
|
|
229
|
+
captured: list[tuple] = []
|
|
230
|
+
monkeypatch.setattr("paircode.installer.invoke", _fake_cliworker_invoke_success(captured))
|
|
214
231
|
|
|
232
|
+
results = inst.install_all()
|
|
215
233
|
actions = {r.cli_name: r.action for r in results}
|
|
216
234
|
assert actions["codex"] == "installed"
|
|
217
|
-
|
|
235
|
+
|
|
236
|
+
codex_calls = [c for c in captured if c[0] == "codex"]
|
|
237
|
+
assert any(
|
|
238
|
+
c[1] == ("marketplace", "add", "starshipagentic/paircode-codex")
|
|
239
|
+
for c in codex_calls
|
|
240
|
+
), f"expected `codex marketplace add starshipagentic/paircode-codex`, got {codex_calls}"
|
|
241
|
+
|
|
242
|
+
# Never writes the broken legacy rules file
|
|
218
243
|
assert not (fake_home / ".codex" / "rules" / "paircode.rules").exists()
|
|
219
244
|
|
|
220
245
|
|
|
221
|
-
def
|
|
222
|
-
"""
|
|
223
|
-
~/.gemini/commands/paircode.toml with description + prompt fields."""
|
|
246
|
+
def test_installer_gemini_calls_extensions_install_via_invoke(tmp_path, monkeypatch):
|
|
247
|
+
"""paircode v0.10 runs `gemini extensions install <url> --consent`."""
|
|
224
248
|
fake_home = tmp_path / "home"
|
|
225
249
|
fake_home.mkdir()
|
|
226
250
|
monkeypatch.setenv("HOME", str(fake_home))
|
|
@@ -233,17 +257,90 @@ def test_installer_writes_gemini_toml_command(tmp_path, monkeypatch):
|
|
|
233
257
|
importlib.reload(inst)
|
|
234
258
|
|
|
235
259
|
monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
|
|
236
|
-
|
|
260
|
+
captured: list[tuple] = []
|
|
261
|
+
monkeypatch.setattr("paircode.installer.invoke", _fake_cliworker_invoke_success(captured))
|
|
237
262
|
|
|
263
|
+
results = inst.install_all()
|
|
238
264
|
actions = {r.cli_name: r.action for r in results}
|
|
239
265
|
assert actions["gemini"] == "installed"
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
266
|
+
|
|
267
|
+
gemini_installs = [
|
|
268
|
+
c for c in captured
|
|
269
|
+
if c[0] == "gemini" and c[1][0:2] == ("extensions", "install") and "--consent" in c[1]
|
|
270
|
+
]
|
|
271
|
+
assert gemini_installs, f"expected gemini extensions install --consent; got {captured}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_installer_codex_idempotent_when_already_registered(tmp_path, monkeypatch):
|
|
275
|
+
"""If the codex marketplace is already registered, install returns 'already'
|
|
276
|
+
without re-invoking `codex marketplace add`."""
|
|
277
|
+
fake_home = tmp_path / "home"
|
|
278
|
+
fake_home.mkdir()
|
|
279
|
+
(fake_home / ".codex").mkdir()
|
|
280
|
+
(fake_home / ".codex" / "config.toml").write_text(
|
|
281
|
+
'[marketplaces.paircode]\nsource = "https://github.com/starshipagentic/paircode-codex.git"\n'
|
|
282
|
+
)
|
|
283
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
284
|
+
import importlib
|
|
285
|
+
|
|
286
|
+
import paircode.detect as d
|
|
287
|
+
import paircode.installer as inst
|
|
288
|
+
|
|
289
|
+
importlib.reload(d)
|
|
290
|
+
importlib.reload(inst)
|
|
291
|
+
|
|
292
|
+
monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
|
|
293
|
+
captured: list[tuple] = []
|
|
294
|
+
monkeypatch.setattr("paircode.installer.invoke", _fake_cliworker_invoke_success(captured))
|
|
295
|
+
|
|
296
|
+
results = inst.install_all()
|
|
297
|
+
codex_result = next(r for r in results if r.cli_name == "codex")
|
|
298
|
+
assert codex_result.action == "already", (
|
|
299
|
+
f"expected action='already'; got {codex_result.action!r} — {codex_result.message}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Marketplace-add must NOT have been invoked on codex
|
|
303
|
+
codex_add_calls = [c for c in captured if c[0] == "codex" and "marketplace" in c[1]]
|
|
304
|
+
assert not codex_add_calls, f"should not re-invoke marketplace add; got {codex_add_calls}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_installer_fails_gracefully_with_actionable_message(tmp_path, monkeypatch):
|
|
308
|
+
"""When invoke() returns .ok=False, installer surfaces the command to run
|
|
309
|
+
manually (copy-paste friendly)."""
|
|
310
|
+
fake_home = tmp_path / "home"
|
|
311
|
+
fake_home.mkdir()
|
|
312
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
313
|
+
import importlib
|
|
314
|
+
|
|
315
|
+
import paircode.detect as d
|
|
316
|
+
import paircode.installer as inst
|
|
317
|
+
|
|
318
|
+
importlib.reload(d)
|
|
319
|
+
importlib.reload(inst)
|
|
320
|
+
|
|
321
|
+
monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
|
|
322
|
+
|
|
323
|
+
def failing_invoke(cli, *args, **kwargs):
|
|
324
|
+
from cliworker.core import CLIResult
|
|
325
|
+
from cliworker.registry import CLISpec
|
|
326
|
+
|
|
327
|
+
return CLIResult(
|
|
328
|
+
spec=CLISpec(cli=cli), ok=False, stdout="", stderr="simulated failure",
|
|
329
|
+
duration_s=0.01, returncode=1, argv=[cli, *args], skipped_reason=None,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
monkeypatch.setattr("paircode.installer.invoke", failing_invoke)
|
|
333
|
+
|
|
334
|
+
results = inst.install_all()
|
|
335
|
+
|
|
336
|
+
codex_r = next(r for r in results if r.cli_name == "codex")
|
|
337
|
+
assert codex_r.action == "failed"
|
|
338
|
+
assert "codex marketplace add starshipagentic/paircode-codex" in codex_r.message
|
|
339
|
+
|
|
340
|
+
gemini_r = next(r for r in results if r.cli_name == "gemini")
|
|
341
|
+
assert gemini_r.action == "failed"
|
|
342
|
+
assert "gemini extensions install" in gemini_r.message
|
|
343
|
+
assert "--consent" in gemini_r.message
|
|
247
344
|
|
|
248
345
|
|
|
249
346
|
# ---------------------------------------------------------------------------
|
|
@@ -54,12 +54,13 @@ def test_handshake_shows_table(tmp_path, monkeypatch):
|
|
|
54
54
|
assert name in result.output
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def
|
|
58
|
-
"""
|
|
57
|
+
def test_install_writes_claude_and_invokes_native_register(tmp_path, monkeypatch):
|
|
58
|
+
"""v0.10: claude is file-drop, codex + gemini use native-register via
|
|
59
|
+
cliworker.invoke(). Verify the right file lands AND the right subprocess
|
|
60
|
+
commands get called."""
|
|
59
61
|
fake_home = tmp_path / "home"
|
|
60
62
|
fake_home.mkdir()
|
|
61
63
|
monkeypatch.setenv("HOME", str(fake_home))
|
|
62
|
-
# Re-import so Path.home() resolves to the new HOME
|
|
63
64
|
import importlib
|
|
64
65
|
|
|
65
66
|
import paircode.detect as d
|
|
@@ -68,43 +69,62 @@ def test_install_writes_to_tmp_claude(tmp_path, monkeypatch):
|
|
|
68
69
|
importlib.reload(d)
|
|
69
70
|
importlib.reload(inst)
|
|
70
71
|
|
|
71
|
-
# Force all three CLIs "installed" by monkeypatching shutil.which
|
|
72
72
|
def fake_which(binary):
|
|
73
73
|
return f"/fake/bin/{binary}"
|
|
74
74
|
|
|
75
75
|
monkeypatch.setattr("shutil.which", fake_which)
|
|
76
76
|
|
|
77
|
+
# Capture invoke() calls — codex + gemini installs go through it
|
|
78
|
+
invoked: list[tuple] = []
|
|
79
|
+
|
|
80
|
+
def fake_invoke(cli, *args, **kwargs):
|
|
81
|
+
from cliworker.core import CLIResult
|
|
82
|
+
from cliworker.registry import CLISpec
|
|
83
|
+
|
|
84
|
+
invoked.append((cli, args))
|
|
85
|
+
# For idempotency check calls (gemini extensions list): return empty stdout
|
|
86
|
+
# so it's "not installed" and the real install runs.
|
|
87
|
+
if args and args[0] == "extensions" and "list" in args:
|
|
88
|
+
return CLIResult(
|
|
89
|
+
spec=CLISpec(cli=cli), ok=True, stdout="", stderr="",
|
|
90
|
+
duration_s=0.01, returncode=0, argv=[cli, *args], skipped_reason=None,
|
|
91
|
+
)
|
|
92
|
+
# For actual install calls, succeed.
|
|
93
|
+
return CLIResult(
|
|
94
|
+
spec=CLISpec(cli=cli), ok=True, stdout="installed", stderr="",
|
|
95
|
+
duration_s=0.1, returncode=0, argv=[cli, *args], skipped_reason=None,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
monkeypatch.setattr("paircode.installer.invoke", fake_invoke)
|
|
99
|
+
|
|
77
100
|
results = inst.install_all()
|
|
78
101
|
actions = {r.cli_name: r.action for r in results}
|
|
79
|
-
|
|
102
|
+
|
|
80
103
|
assert actions["claude"] == "installed"
|
|
81
104
|
assert actions["codex"] == "installed"
|
|
82
105
|
assert actions["gemini"] == "installed"
|
|
83
106
|
|
|
84
|
-
#
|
|
107
|
+
# Claude: file-drop landed on disk
|
|
85
108
|
claude_cmd = fake_home / ".claude" / "commands" / "paircode.md"
|
|
86
109
|
assert claude_cmd.exists()
|
|
87
110
|
assert "paircode" in claude_cmd.read_text()
|
|
88
111
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert "
|
|
102
|
-
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
assert not codex_rules.exists(), (
|
|
106
|
-
f"paircode must never write {codex_rules} — that file breaks codex's rule loader"
|
|
107
|
-
)
|
|
112
|
+
# Codex: the invoke() call ran `codex marketplace add starshipagentic/paircode-codex`
|
|
113
|
+
codex_calls = [c for c in invoked if c[0] == "codex"]
|
|
114
|
+
assert any(
|
|
115
|
+
c[1] == ("marketplace", "add", "starshipagentic/paircode-codex")
|
|
116
|
+
for c in codex_calls
|
|
117
|
+
), f"Expected codex marketplace add call; got {codex_calls}"
|
|
118
|
+
|
|
119
|
+
# Gemini: the invoke() call ran `gemini extensions install <url> --consent`
|
|
120
|
+
gemini_install = [
|
|
121
|
+
c for c in invoked
|
|
122
|
+
if c[0] == "gemini" and "install" in c[1] and "--consent" in c[1]
|
|
123
|
+
]
|
|
124
|
+
assert gemini_install, f"Expected gemini extensions install --consent; got {invoked}"
|
|
125
|
+
|
|
126
|
+
# We do NOT write the legacy broken codex rules file
|
|
127
|
+
assert not (fake_home / ".codex" / "rules" / "paircode.rules").exists()
|
|
108
128
|
|
|
109
129
|
|
|
110
130
|
def test_install_cleans_legacy_codex_rules_file(tmp_path, monkeypatch):
|
|
@@ -1,188 +0,0 @@
|
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|