codeforerunner 0.3.0__py3-none-any.whl
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.
- codeforerunner/__init__.py +1 -0
- codeforerunner/check.py +156 -0
- codeforerunner/cli.py +236 -0
- codeforerunner/config.py +176 -0
- codeforerunner/doctor.py +321 -0
- codeforerunner/installer.py +304 -0
- codeforerunner/mcp_server.py +177 -0
- codeforerunner/providers/__init__.py +36 -0
- codeforerunner/providers/anthropic.py +61 -0
- codeforerunner/providers/base.py +31 -0
- codeforerunner/providers/google.py +62 -0
- codeforerunner/providers/ollama.py +56 -0
- codeforerunner/providers/openai.py +59 -0
- codeforerunner-0.3.0.dist-info/METADATA +120 -0
- codeforerunner-0.3.0.dist-info/RECORD +19 -0
- codeforerunner-0.3.0.dist-info/WHEEL +5 -0
- codeforerunner-0.3.0.dist-info/entry_points.txt +2 -0
- codeforerunner-0.3.0.dist-info/licenses/LICENSE.md +71 -0
- codeforerunner-0.3.0.dist-info/top_level.txt +1 -0
codeforerunner/doctor.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""`forerunner doctor` — single-screen health report. See SPEC.md §T35."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from codeforerunner.config import CONFIG_FILENAME, ConfigError, load_from_repo
|
|
14
|
+
|
|
15
|
+
CANONICAL_REL = Path("agent/codeforerunner.skill.md")
|
|
16
|
+
SKILL_COPIES_REL: tuple[Path, ...] = (
|
|
17
|
+
Path("plugins/codeforerunner/skills/codeforerunner/SKILL.md"),
|
|
18
|
+
Path("skills/codeforerunner/SKILL.md"),
|
|
19
|
+
)
|
|
20
|
+
MARKETPLACE_REL = Path("plugins/codex/marketplace.json")
|
|
21
|
+
|
|
22
|
+
MARKER_BEGIN = "<!-- forerunner:begin managed=codeforerunner.skill -->"
|
|
23
|
+
MARKER_END = "<!-- forerunner:end -->"
|
|
24
|
+
|
|
25
|
+
_DEFAULT_PROVIDER_ENV = {
|
|
26
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
27
|
+
"openai": "OPENAI_API_KEY",
|
|
28
|
+
"google": "GOOGLE_API_KEY",
|
|
29
|
+
"ollama": "OLLAMA_HOST",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Finding:
|
|
35
|
+
severity: str # "ok" | "warn" | "error"
|
|
36
|
+
check: str
|
|
37
|
+
message: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _installed_skill_destinations() -> list[Path]:
|
|
41
|
+
home = Path(os.path.expanduser("~"))
|
|
42
|
+
return [
|
|
43
|
+
home / ".codex/skills/codeforerunner/SKILL.md",
|
|
44
|
+
home / ".claude/plugins/codeforerunner/skills/codeforerunner/SKILL.md",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _installed_marketplace_destination() -> Path:
|
|
49
|
+
return Path(os.path.expanduser("~")) / ".codex/marketplaces/codeforerunner.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_script_module(repo: Path, relpath: str, module_name: str):
|
|
53
|
+
script_path = repo / relpath
|
|
54
|
+
spec = importlib.util.spec_from_file_location(module_name, script_path)
|
|
55
|
+
if spec is None or spec.loader is None: # pragma: no cover - defensive
|
|
56
|
+
raise RuntimeError(f"cannot load {script_path}")
|
|
57
|
+
module = importlib.util.module_from_spec(spec)
|
|
58
|
+
sys.modules[module_name] = module
|
|
59
|
+
spec.loader.exec_module(module)
|
|
60
|
+
return module
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_skill_body_parity(repo: Path) -> list[Finding]:
|
|
64
|
+
try:
|
|
65
|
+
skill_mod = _load_script_module(
|
|
66
|
+
repo, "scripts/validate_skill_copies.py", "_forerunner_doctor_skill_copies"
|
|
67
|
+
)
|
|
68
|
+
strip_frontmatter: Callable[[str], str] = skill_mod.strip_frontmatter
|
|
69
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
70
|
+
return [Finding("error", "skill-body-parity", f"loader failure: {exc}")]
|
|
71
|
+
|
|
72
|
+
canonical_path = repo / CANONICAL_REL
|
|
73
|
+
if not canonical_path.is_file():
|
|
74
|
+
return [
|
|
75
|
+
Finding(
|
|
76
|
+
"error",
|
|
77
|
+
"skill-body-parity",
|
|
78
|
+
f"canonical skill missing: {CANONICAL_REL}",
|
|
79
|
+
)
|
|
80
|
+
]
|
|
81
|
+
canonical_body = strip_frontmatter(canonical_path.read_text(encoding="utf-8"))
|
|
82
|
+
|
|
83
|
+
findings: list[Finding] = []
|
|
84
|
+
for rel in SKILL_COPIES_REL:
|
|
85
|
+
p = repo / rel
|
|
86
|
+
if not p.is_file():
|
|
87
|
+
findings.append(
|
|
88
|
+
Finding("error", "skill-body-parity", f"copy missing: {rel}")
|
|
89
|
+
)
|
|
90
|
+
continue
|
|
91
|
+
body = strip_frontmatter(p.read_text(encoding="utf-8"))
|
|
92
|
+
if body != canonical_body:
|
|
93
|
+
findings.append(
|
|
94
|
+
Finding("error", "skill-body-parity", f"body drift in {rel}")
|
|
95
|
+
)
|
|
96
|
+
if not findings:
|
|
97
|
+
findings.append(
|
|
98
|
+
Finding(
|
|
99
|
+
"ok",
|
|
100
|
+
"skill-body-parity",
|
|
101
|
+
f"canonical body matches {len(SKILL_COPIES_REL)} distributed copies",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return findings
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_codex_marketplace(repo: Path) -> list[Finding]:
|
|
108
|
+
try:
|
|
109
|
+
mp_mod = _load_script_module(
|
|
110
|
+
repo,
|
|
111
|
+
"scripts/validate_codex_marketplace.py",
|
|
112
|
+
"_forerunner_doctor_codex_marketplace",
|
|
113
|
+
)
|
|
114
|
+
validate: Callable[[Path], list[str]] = mp_mod.validate
|
|
115
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
116
|
+
return [Finding("error", "codex-marketplace", f"loader failure: {exc}")]
|
|
117
|
+
|
|
118
|
+
errors = validate(repo)
|
|
119
|
+
if not errors:
|
|
120
|
+
return [
|
|
121
|
+
Finding("ok", "codex-marketplace", f"{MARKETPLACE_REL} validates")
|
|
122
|
+
]
|
|
123
|
+
return [Finding("error", "codex-marketplace", msg) for msg in errors]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_installed_destinations(repo: Path) -> list[Finding]:
|
|
127
|
+
findings: list[Finding] = []
|
|
128
|
+
|
|
129
|
+
for dest in _installed_skill_destinations():
|
|
130
|
+
if not dest.exists():
|
|
131
|
+
findings.append(
|
|
132
|
+
Finding(
|
|
133
|
+
"ok",
|
|
134
|
+
"installed-destinations",
|
|
135
|
+
f"{dest}: not installed (skip)",
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
text = dest.read_text(encoding="utf-8")
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
findings.append(
|
|
143
|
+
Finding(
|
|
144
|
+
"warn",
|
|
145
|
+
"installed-destinations",
|
|
146
|
+
f"{dest}: unreadable ({exc})",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
continue
|
|
150
|
+
if MARKER_BEGIN in text and MARKER_END in text:
|
|
151
|
+
findings.append(
|
|
152
|
+
Finding(
|
|
153
|
+
"ok",
|
|
154
|
+
"installed-destinations",
|
|
155
|
+
f"{dest}: managed (markers present)",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
findings.append(
|
|
160
|
+
Finding(
|
|
161
|
+
"warn",
|
|
162
|
+
"installed-destinations",
|
|
163
|
+
f"{dest}: exists without managed-region markers (installer will refuse to overwrite)",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
mp_dest = _installed_marketplace_destination()
|
|
168
|
+
if not mp_dest.exists():
|
|
169
|
+
findings.append(
|
|
170
|
+
Finding(
|
|
171
|
+
"ok",
|
|
172
|
+
"installed-destinations",
|
|
173
|
+
f"{mp_dest}: not installed (skip)",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
src = repo / MARKETPLACE_REL
|
|
178
|
+
try:
|
|
179
|
+
installed = mp_dest.read_text(encoding="utf-8").rstrip()
|
|
180
|
+
canonical = src.read_text(encoding="utf-8").rstrip()
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
findings.append(
|
|
183
|
+
Finding(
|
|
184
|
+
"warn",
|
|
185
|
+
"installed-destinations",
|
|
186
|
+
f"{mp_dest}: unreadable ({exc})",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
if installed == canonical:
|
|
191
|
+
findings.append(
|
|
192
|
+
Finding(
|
|
193
|
+
"ok",
|
|
194
|
+
"installed-destinations",
|
|
195
|
+
f"{mp_dest}: matches {MARKETPLACE_REL}",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
findings.append(
|
|
200
|
+
Finding(
|
|
201
|
+
"warn",
|
|
202
|
+
"installed-destinations",
|
|
203
|
+
f"{mp_dest}: drifted from {MARKETPLACE_REL}",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return findings
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _check_config_loadable(repo: Path) -> list[Finding]:
|
|
211
|
+
cfg_path = repo / CONFIG_FILENAME
|
|
212
|
+
if not cfg_path.is_file():
|
|
213
|
+
return [
|
|
214
|
+
Finding(
|
|
215
|
+
"ok",
|
|
216
|
+
"config-loadable",
|
|
217
|
+
f"no {CONFIG_FILENAME}; check is a no-op",
|
|
218
|
+
)
|
|
219
|
+
]
|
|
220
|
+
try:
|
|
221
|
+
load_from_repo(repo)
|
|
222
|
+
except ConfigError as exc:
|
|
223
|
+
return [Finding("error", "config-loadable", str(exc))]
|
|
224
|
+
return [Finding("ok", "config-loadable", f"{CONFIG_FILENAME} parses cleanly")]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
228
|
+
cfg_path = repo / CONFIG_FILENAME
|
|
229
|
+
if not cfg_path.is_file():
|
|
230
|
+
return [
|
|
231
|
+
Finding(
|
|
232
|
+
"ok",
|
|
233
|
+
"provider-api-key",
|
|
234
|
+
f"no {CONFIG_FILENAME}; provider key not checked",
|
|
235
|
+
)
|
|
236
|
+
]
|
|
237
|
+
try:
|
|
238
|
+
cfg = load_from_repo(repo)
|
|
239
|
+
except ConfigError:
|
|
240
|
+
# config-loadable check will surface this; skip here
|
|
241
|
+
return [
|
|
242
|
+
Finding(
|
|
243
|
+
"ok",
|
|
244
|
+
"provider-api-key",
|
|
245
|
+
"config unparseable; skipped (see config-loadable)",
|
|
246
|
+
)
|
|
247
|
+
]
|
|
248
|
+
if cfg is None: # pragma: no cover - defensive
|
|
249
|
+
return [
|
|
250
|
+
Finding(
|
|
251
|
+
"ok",
|
|
252
|
+
"provider-api-key",
|
|
253
|
+
f"no {CONFIG_FILENAME}; provider key not checked",
|
|
254
|
+
)
|
|
255
|
+
]
|
|
256
|
+
provider = cfg.provider
|
|
257
|
+
if provider == "ollama":
|
|
258
|
+
return [
|
|
259
|
+
Finding(
|
|
260
|
+
"ok",
|
|
261
|
+
"provider-api-key",
|
|
262
|
+
"ollama needs no API key (OLLAMA_HOST optional)",
|
|
263
|
+
)
|
|
264
|
+
]
|
|
265
|
+
env_var = cfg.api_key_env.get(provider) or _DEFAULT_PROVIDER_ENV.get(provider, "")
|
|
266
|
+
if os.environ.get(env_var):
|
|
267
|
+
return [Finding("ok", "provider-api-key", f"{provider}: {env_var} is set")]
|
|
268
|
+
return [
|
|
269
|
+
Finding(
|
|
270
|
+
"warn",
|
|
271
|
+
"provider-api-key",
|
|
272
|
+
f"{provider}: ${env_var} is not set; `forerunner generate` will refuse to run",
|
|
273
|
+
)
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def run(repo: Path) -> list[Finding]:
|
|
278
|
+
repo = repo.resolve()
|
|
279
|
+
findings: list[Finding] = []
|
|
280
|
+
findings.extend(_check_skill_body_parity(repo))
|
|
281
|
+
findings.extend(_check_codex_marketplace(repo))
|
|
282
|
+
findings.extend(_check_installed_destinations(repo))
|
|
283
|
+
findings.extend(_check_config_loadable(repo))
|
|
284
|
+
findings.extend(_check_provider_api_key(repo))
|
|
285
|
+
return findings
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def format_report(findings: list[Finding]) -> str:
|
|
289
|
+
lines = [f"[{f.severity}] {f.check}: {f.message}" for f in findings]
|
|
290
|
+
counts = {"ok": 0, "warn": 0, "error": 0}
|
|
291
|
+
for f in findings:
|
|
292
|
+
counts[f.severity] = counts.get(f.severity, 0) + 1
|
|
293
|
+
summary = (
|
|
294
|
+
f"summary: {counts.get('ok', 0)} ok, "
|
|
295
|
+
f"{counts.get('warn', 0)} warn, "
|
|
296
|
+
f"{counts.get('error', 0)} error"
|
|
297
|
+
)
|
|
298
|
+
lines.append(summary)
|
|
299
|
+
return "\n".join(lines)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main(argv: list[str] | None = None) -> int:
|
|
303
|
+
parser = argparse.ArgumentParser(
|
|
304
|
+
prog="forerunner doctor",
|
|
305
|
+
description="Single-screen health report for codeforerunner repo.",
|
|
306
|
+
)
|
|
307
|
+
parser.add_argument(
|
|
308
|
+
"--repo",
|
|
309
|
+
type=Path,
|
|
310
|
+
default=Path.cwd(),
|
|
311
|
+
help="repo root (default: cwd)",
|
|
312
|
+
)
|
|
313
|
+
args = parser.parse_args(argv)
|
|
314
|
+
|
|
315
|
+
findings = run(args.repo)
|
|
316
|
+
print(format_report(findings))
|
|
317
|
+
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__": # pragma: no cover
|
|
321
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Idempotent skill installer. See SPEC.md §D.installer (cites V8, V10, V11, V12)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable, Literal
|
|
12
|
+
|
|
13
|
+
MARKER_BEGIN = "<!-- forerunner:begin managed=codeforerunner.skill -->"
|
|
14
|
+
MARKER_END = "<!-- forerunner:end -->"
|
|
15
|
+
|
|
16
|
+
CANONICAL_REL = Path("agent/codeforerunner.skill.md")
|
|
17
|
+
MARKETPLACE_REL = Path("plugins/codex/marketplace.json")
|
|
18
|
+
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_USAGE = 2
|
|
21
|
+
EXIT_BODY_MISMATCH = 3
|
|
22
|
+
EXIT_UNMANAGED_DEST = 4
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Target:
|
|
27
|
+
name: str
|
|
28
|
+
path: Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _home() -> Path:
|
|
32
|
+
return Path(os.path.expanduser("~"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_target(agent: str, override: Path | None) -> Target:
|
|
36
|
+
if agent == "generic":
|
|
37
|
+
if override is None:
|
|
38
|
+
raise ValueError("generic target requires --path PATH")
|
|
39
|
+
return Target(agent, override.expanduser().resolve())
|
|
40
|
+
if override is not None:
|
|
41
|
+
return Target(agent, override.expanduser().resolve())
|
|
42
|
+
home = _home()
|
|
43
|
+
if agent == "codex":
|
|
44
|
+
return Target(agent, home / ".codex/skills/codeforerunner/SKILL.md")
|
|
45
|
+
if agent == "claude":
|
|
46
|
+
return Target(agent, home / ".claude/plugins/codeforerunner/skills/codeforerunner/SKILL.md")
|
|
47
|
+
raise ValueError(f"unknown agent '{agent}' (expected: codex, claude, generic)")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_marketplace_target(agent: str, override: Path | None) -> Target:
|
|
51
|
+
if agent == "generic":
|
|
52
|
+
if override is None:
|
|
53
|
+
raise ValueError("generic marketplace target requires --path PATH")
|
|
54
|
+
return Target(agent, override.expanduser().resolve())
|
|
55
|
+
if override is not None:
|
|
56
|
+
return Target(agent, override.expanduser().resolve())
|
|
57
|
+
if agent == "codex":
|
|
58
|
+
return Target(agent, _home() / ".codex/marketplaces/codeforerunner.json")
|
|
59
|
+
raise ValueError(f"marketplace not supported for agent '{agent}' (expected: codex)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def strip_frontmatter(text: str) -> str:
|
|
63
|
+
"""Body extraction matching scripts/validate_skill_copies.py."""
|
|
64
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
65
|
+
lines = text.split("\n")
|
|
66
|
+
if lines and lines[0] == "---":
|
|
67
|
+
for i in range(1, len(lines)):
|
|
68
|
+
if lines[i] == "---":
|
|
69
|
+
return "\n".join(lines[i + 1 :]).strip()
|
|
70
|
+
return text.strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extract_frontmatter(text: str) -> str:
|
|
74
|
+
"""Return frontmatter block (incl. fences) or '' if none."""
|
|
75
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
76
|
+
lines = text.split("\n")
|
|
77
|
+
if lines and lines[0] == "---":
|
|
78
|
+
for i in range(1, len(lines)):
|
|
79
|
+
if lines[i] == "---":
|
|
80
|
+
return "\n".join(lines[: i + 1]) + "\n"
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _hash(s: str) -> str:
|
|
85
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _hash_bytes(b: bytes) -> str:
|
|
89
|
+
return hashlib.sha256(b).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render(source_text: str, dest_existing: str | None, agent: str) -> str:
|
|
93
|
+
"""Render dest content: preserve dest frontmatter if present; wrap source body in markers."""
|
|
94
|
+
body = strip_frontmatter(source_text)
|
|
95
|
+
if dest_existing:
|
|
96
|
+
fm = extract_frontmatter(dest_existing)
|
|
97
|
+
else:
|
|
98
|
+
fm = ""
|
|
99
|
+
managed = f"{MARKER_BEGIN}\n{body}\n{MARKER_END}\n"
|
|
100
|
+
if fm:
|
|
101
|
+
return fm + managed
|
|
102
|
+
return managed
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_markers(text: str) -> tuple[int, int] | None:
|
|
106
|
+
a = text.find(MARKER_BEGIN)
|
|
107
|
+
if a < 0:
|
|
108
|
+
return None
|
|
109
|
+
b = text.find(MARKER_END, a + len(MARKER_BEGIN))
|
|
110
|
+
if b < 0:
|
|
111
|
+
return None
|
|
112
|
+
return (a, b + len(MARKER_END))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def overlay(dest_text: str, source_body: str) -> str:
|
|
116
|
+
"""Replace managed region in-place. Caller has verified markers exist."""
|
|
117
|
+
span = find_markers(dest_text)
|
|
118
|
+
assert span is not None
|
|
119
|
+
a, b = span
|
|
120
|
+
managed = f"{MARKER_BEGIN}\n{source_body}\n{MARKER_END}"
|
|
121
|
+
return dest_text[:a] + managed + dest_text[b:]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Plan:
|
|
126
|
+
action: str # "create" | "update" | "skip" | "abort"
|
|
127
|
+
reason: str
|
|
128
|
+
target: Target
|
|
129
|
+
new_content: str | None = None
|
|
130
|
+
|
|
131
|
+
def write(self) -> None:
|
|
132
|
+
if self.action in ("skip", "abort"):
|
|
133
|
+
return
|
|
134
|
+
assert self.new_content is not None
|
|
135
|
+
self.target.path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
self.target.path.write_text(self.new_content, encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def plan_install(
|
|
140
|
+
*,
|
|
141
|
+
source_path: Path,
|
|
142
|
+
canonical_path: Path,
|
|
143
|
+
target: Target,
|
|
144
|
+
) -> tuple[Plan, int]:
|
|
145
|
+
"""Return (plan, exit_code). exit_code ≠ 0 → abort."""
|
|
146
|
+
src_text = source_path.read_text(encoding="utf-8")
|
|
147
|
+
canonical_text = canonical_path.read_text(encoding="utf-8")
|
|
148
|
+
|
|
149
|
+
src_body = strip_frontmatter(src_text)
|
|
150
|
+
canon_body = strip_frontmatter(canonical_text)
|
|
151
|
+
if src_body != canon_body:
|
|
152
|
+
return (
|
|
153
|
+
Plan(
|
|
154
|
+
action="abort",
|
|
155
|
+
reason=(
|
|
156
|
+
f"body-parity violation (V10): source body differs from canonical "
|
|
157
|
+
f"{canonical_path}"
|
|
158
|
+
),
|
|
159
|
+
target=target,
|
|
160
|
+
),
|
|
161
|
+
EXIT_BODY_MISMATCH,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
dest = target.path
|
|
165
|
+
if dest.exists():
|
|
166
|
+
dest_text = dest.read_text(encoding="utf-8")
|
|
167
|
+
if find_markers(dest_text) is None:
|
|
168
|
+
return (
|
|
169
|
+
Plan(
|
|
170
|
+
action="abort",
|
|
171
|
+
reason=(
|
|
172
|
+
f"destination exists without managed markers ({dest}); refusing "
|
|
173
|
+
"to overwrite user content"
|
|
174
|
+
),
|
|
175
|
+
target=target,
|
|
176
|
+
),
|
|
177
|
+
EXIT_UNMANAGED_DEST,
|
|
178
|
+
)
|
|
179
|
+
new_text = overlay(dest_text, src_body)
|
|
180
|
+
if _hash(new_text) == _hash(dest_text):
|
|
181
|
+
return Plan(action="skip", reason="dest matches source (V12 idempotent)", target=target), EXIT_OK
|
|
182
|
+
return Plan(action="update", reason="overlay managed region", target=target, new_content=new_text), EXIT_OK
|
|
183
|
+
|
|
184
|
+
new_text = render(src_text, None, target.name)
|
|
185
|
+
return Plan(action="create", reason="dest absent", target=target, new_content=new_text), EXIT_OK
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def plan_marketplace(*, source_path: Path, target: Target) -> tuple[Plan, int]:
|
|
189
|
+
"""Idempotent JSON manifest install. Hash-equality on whole file (trimmed)."""
|
|
190
|
+
src_bytes = source_path.read_bytes()
|
|
191
|
+
src_trimmed = src_bytes.rstrip()
|
|
192
|
+
src_text = src_bytes.decode("utf-8")
|
|
193
|
+
dest = target.path
|
|
194
|
+
if dest.exists():
|
|
195
|
+
dest_bytes = dest.read_bytes()
|
|
196
|
+
dest_trimmed = dest_bytes.rstrip()
|
|
197
|
+
if _hash_bytes(src_trimmed) == _hash_bytes(dest_trimmed):
|
|
198
|
+
return (
|
|
199
|
+
Plan(action="skip", reason="dest matches source (V12 idempotent)", target=target),
|
|
200
|
+
EXIT_OK,
|
|
201
|
+
)
|
|
202
|
+
return (
|
|
203
|
+
Plan(
|
|
204
|
+
action="abort",
|
|
205
|
+
reason=(
|
|
206
|
+
f"destination exists and differs from source ({dest}); refusing "
|
|
207
|
+
"to overwrite user content"
|
|
208
|
+
),
|
|
209
|
+
target=target,
|
|
210
|
+
),
|
|
211
|
+
EXIT_UNMANAGED_DEST,
|
|
212
|
+
)
|
|
213
|
+
return (
|
|
214
|
+
Plan(action="create", reason="dest absent", target=target, new_content=src_text),
|
|
215
|
+
EXIT_OK,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def install(
|
|
220
|
+
*,
|
|
221
|
+
agent: str,
|
|
222
|
+
repo_root: Path,
|
|
223
|
+
source: Path | None,
|
|
224
|
+
dest_override: Path | None,
|
|
225
|
+
check_only: bool,
|
|
226
|
+
kind: Literal["skill", "marketplace"] = "skill",
|
|
227
|
+
out=None,
|
|
228
|
+
err=None,
|
|
229
|
+
) -> int:
|
|
230
|
+
out = out or sys.stdout
|
|
231
|
+
err = err or sys.stderr
|
|
232
|
+
|
|
233
|
+
if kind == "marketplace":
|
|
234
|
+
try:
|
|
235
|
+
target = resolve_marketplace_target(agent, dest_override)
|
|
236
|
+
except ValueError as e:
|
|
237
|
+
print(f"error: {e}", file=err)
|
|
238
|
+
return EXIT_USAGE
|
|
239
|
+
src_path = source if source is not None else (repo_root / MARKETPLACE_REL)
|
|
240
|
+
if not src_path.is_file():
|
|
241
|
+
print(f"error: marketplace source not found: {src_path}", file=err)
|
|
242
|
+
return EXIT_USAGE
|
|
243
|
+
plan, code = plan_marketplace(source_path=src_path, target=target)
|
|
244
|
+
prefix = "would " if check_only else ""
|
|
245
|
+
stream = err if code != EXIT_OK else out
|
|
246
|
+
print(f"{prefix}{plan.action}: {target.path} ({plan.reason})", file=stream)
|
|
247
|
+
if code != EXIT_OK:
|
|
248
|
+
return code
|
|
249
|
+
if not check_only:
|
|
250
|
+
plan.write()
|
|
251
|
+
return EXIT_OK
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
target = resolve_target(agent, dest_override)
|
|
255
|
+
except ValueError as e:
|
|
256
|
+
print(f"error: {e}", file=err)
|
|
257
|
+
return EXIT_USAGE
|
|
258
|
+
|
|
259
|
+
canonical = repo_root / CANONICAL_REL
|
|
260
|
+
src_path = source if source is not None else canonical
|
|
261
|
+
if not src_path.is_file():
|
|
262
|
+
print(f"error: source not found: {src_path}", file=err)
|
|
263
|
+
return EXIT_USAGE
|
|
264
|
+
if not canonical.is_file():
|
|
265
|
+
print(f"error: canonical not found: {canonical}", file=err)
|
|
266
|
+
return EXIT_USAGE
|
|
267
|
+
|
|
268
|
+
plan, code = plan_install(source_path=src_path, canonical_path=canonical, target=target)
|
|
269
|
+
prefix = "would " if check_only else ""
|
|
270
|
+
stream = err if code != EXIT_OK else out
|
|
271
|
+
print(f"{prefix}{plan.action}: {target.path} ({plan.reason})", file=stream)
|
|
272
|
+
if code != EXIT_OK:
|
|
273
|
+
return code
|
|
274
|
+
if not check_only:
|
|
275
|
+
plan.write()
|
|
276
|
+
return EXIT_OK
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def add_subparser(sub: argparse._SubParsersAction) -> None:
|
|
280
|
+
p = sub.add_parser("install", help="install skill into agent-specific directory (D.installer)")
|
|
281
|
+
p.add_argument("agent", choices=["codex", "claude", "generic"])
|
|
282
|
+
p.add_argument("--check", action="store_true", help="dry-run: print plan, write nothing")
|
|
283
|
+
p.add_argument("--path", type=Path, help="dest path override (required for generic)")
|
|
284
|
+
p.add_argument("--source", type=Path, help="source skill file (default: agent/codeforerunner.skill.md)")
|
|
285
|
+
p.add_argument(
|
|
286
|
+
"--marketplace",
|
|
287
|
+
action="store_true",
|
|
288
|
+
help="install a marketplace manifest (codex only) instead of the skill body",
|
|
289
|
+
)
|
|
290
|
+
p.set_defaults(func=_cli_entry)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _cli_entry(args: argparse.Namespace) -> int:
|
|
294
|
+
from codeforerunner.cli import _repo_root # local import to avoid cycle
|
|
295
|
+
|
|
296
|
+
root = _repo_root(Path(args.repo) if args.repo else None)
|
|
297
|
+
return install(
|
|
298
|
+
agent=args.agent,
|
|
299
|
+
repo_root=root,
|
|
300
|
+
source=args.source,
|
|
301
|
+
dest_override=args.path,
|
|
302
|
+
check_only=args.check,
|
|
303
|
+
kind="marketplace" if getattr(args, "marketplace", False) else "skill",
|
|
304
|
+
)
|