Coverage for src/keel/install.py: 100%

379 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +0000

1"""`keel install-adapter` — install the packaged command adapters into a project. 

2 

3keel ships its agentic workflows once (as markdown under ``keel/adapters/commands/``) and 

4installs them into the **two surfaces** that match how agents actually discover commands — 

5never one copy per agent (that would re-introduce the very file-copy drift keel removes): 

6 

7- ``claude`` — native slash commands at ``.claude/commands/keel/<cmd>.md`` → ``/keel:<cmd>``. 

8- ``skills`` — a **single, shared** skill set at ``.agents/skills/keel-<cmd>/SKILL.md`` that 

9 every non-Claude agent (Codex, Antigravity, Gemini, …) discovers via the repo's skill 

10 mechanism / "chat command wrapper". One universal copy, not one dir per agent. 

11 

12``all`` installs both. The skill body is the same project-neutral adapter (it leans on the 

13``keel`` CLI), wrapped with skill frontmatter so the agents' skill discovery picks it up. 

14""" 

15 

16from __future__ import annotations 

17 

18import hashlib 

19import json 

20import re 

21from dataclasses import dataclass 

22from pathlib import Path 

23 

24from . import __version__ 

25from . import yaml_helper as yaml 

26 

27ADAPTERS = Path(__file__).parent / "adapters" / "commands" 

28 

29#: native Claude slash-command dir (namespaced under ``keel/``). 

30CLAUDE_DIR = ".claude/commands/keel" 

31#: the universal skill dir every non-Claude agent reads. 

32SKILLS_DIR = ".agents/skills" 

33#: skill name prefix, so keel skills sit beside the project's own (e.g. ``source-command-*``). 

34SKILL_PREFIX = "keel-" 

35 

36#: Claude Code plugin command dir (flat ``.md`` files at the plugin root). The plugin is 

37#: named ``keel``, so a flat ``commands/<cmd>.md`` is discovered as ``/keel:<cmd>`` — the same 

38#: surface as the native ``claude`` install, packaged for ``/plugin install keel``. 

39PLUGIN_COMMANDS_DIR = "commands" 

40#: the committed plugin manifest + marketplace catalog live here. 

41PLUGIN_MANIFEST = ".claude-plugin/plugin.json" 

42PLUGIN_MARKETPLACE = ".claude-plugin/marketplace.json" 

43#: the committed Codex plugin manifest — same shape, reuses the same ./skills. 

44CODEX_PLUGIN_MANIFEST = ".codex-plugin/plugin.json" 

45 

46#: the static site's published argument surface, generated from the same frontmatter. 

47SITE_PARAMS_PATH = "website/params.js" 

48#: the header the generated ``params.js`` opens with. It must contain no ``=``: this 

49#: repository's drift checks read the file by splitting on the first one — nothing 

50#: under ``website/`` parses it, the browser just runs the assignment. 

51SITE_PARAMS_HEADER = ( 

52 "/* generated from src/keel/adapters/commands frontmatter — do not hand-edit.\n" 

53 " Regenerate with `make site-params` (keel install-adapter site --root .). */" 

54) 

55#: the global the site's renderers read the argument surface from. 

56SITE_PARAMS_GLOBAL = "window.KEEL_ARGS" 

57 

58#: the logical install surfaces (``all`` fans over these). 

59TARGETS: tuple[str, ...] = ("claude", "skills") 

60STATUS_TARGETS: tuple[str, ...] = ("claude", "skills", "legacy-claude") 

61LEGACY_TARGETS: tuple[str, ...] = ("claude", "skills") 

62LEGACY_CLAUDE_DIR = ".claude/commands" 

63LEGACY_SKILL_PREFIX = "source-command-" 

64PARITY_READY_STATUSES = frozenset({"parity-proven", "deferred"}) 

65 

66MARKER_RE = re.compile(r"\n?<!-- keel-generated: (?P<meta>[^>]*) -->\n?$") 

67 

68 

69@dataclass(frozen=True) 

70class OrphanFileStatus: 

71 """A file under a managed surface directory that keel does not currently manage. 

72 

73 ``category`` is ``"orphan"`` (deterministic, class (a)) or ``"unmanaged"`` (heuristic, 

74 class (b)). ``reason`` is a stable reason code; ``command`` is the marker's ``command=`` 

75 for a stale-marker orphan, or the file stem for a marker-less surface. 

76 """ 

77 

78 surface: str 

79 name: str 

80 path: str 

81 category: str 

82 reason: str 

83 command: str = "" 

84 

85 def as_dict(self) -> dict[str, str]: 

86 """Render as JSON-compatible contract data (sorted-stable).""" 

87 return { 

88 "surface": self.surface, 

89 "name": self.name, 

90 "path": self.path, 

91 "category": self.category, 

92 "reason": self.reason, 

93 "command": self.command, 

94 } 

95 

96 

97@dataclass(frozen=True) 

98class AdapterFileStatus: 

99 surface: str 

100 name: str 

101 path: str 

102 status: str 

103 detail: str = "" 

104 source_sha256: str = "" 

105 installed_sha256: str = "" 

106 expected_sha256: str = "" 

107 

108 

109def _sha256(text: str) -> str: 

110 return hashlib.sha256(text.encode("utf-8")).hexdigest() 

111 

112 

113def _marker(surface: str, command: str, source_text: str, generated_text: str) -> str: 

114 return ( 

115 "<!-- keel-generated: " 

116 f"surface={surface} command={command} keel_version={__version__} " 

117 f"source_sha256={_sha256(source_text)} generated_sha256={_sha256(generated_text)} " 

118 "-->" 

119 ) 

120 

121 

122def _with_marker(surface: str, command: str, source_text: str, generated_text: str) -> str: 

123 marker = _marker(surface, command, source_text, generated_text) 

124 return f"{generated_text.rstrip()}\n\n{marker}\n" 

125 

126 

127def _split_marker(text: str) -> tuple[str, dict[str, str]]: 

128 match = MARKER_RE.search(text) 

129 if not match: 

130 return text, {} 

131 body = text[: match.start()].rstrip() + "\n" 

132 meta: dict[str, str] = {} 

133 for part in match.group("meta").split(): 

134 if "=" in part: 

135 key, value = part.split("=", 1) 

136 meta[key] = value 

137 return body, meta 

138 

139 

140def _expected_files(_src: Path | None = None) -> dict[str, dict[str, tuple[Path, str, str, str]]]: 

141 src = _src or ADAPTERS 

142 expected: dict[str, dict[str, tuple[Path, str, str, str]]] = { 

143 "claude": {}, 

144 "skills": {}, 

145 "legacy-claude": {}, 

146 } 

147 for f in sorted(src.glob("*.md")): 

148 source_text = f.read_text(encoding="utf-8") 

149 command = f.stem 

150 expected["claude"][f.name] = ( 

151 Path(CLAUDE_DIR) / f.name, 

152 command, 

153 source_text, 

154 source_text, 

155 ) 

156 expected["skills"][f"{SKILL_PREFIX}{command}"] = ( 

157 Path(SKILLS_DIR) / f"{SKILL_PREFIX}{command}" / "SKILL.md", 

158 command, 

159 source_text, 

160 render_skill(source_text, command), 

161 ) 

162 expected["legacy-claude"] = _legacy_expected_files( 

163 default_legacy_mappings(_src=_src), _src=_src 

164 )["claude"] 

165 return expected 

166 

167 

168def adapter_names(*, _src: Path | None = None) -> list[str]: 

169 """The command adapters that ship with keel (e.g. ``ship.md``, ``regression.md``).""" 

170 return sorted(p.name for p in (_src or ADAPTERS).glob("*.md")) 

171 

172 

173def _split_frontmatter(text: str) -> tuple[dict, str]: 

174 """Split ``---`` YAML frontmatter from a markdown body. Returns ``(meta, body)``.""" 

175 if text.startswith("---"): 

176 parts = text.split("---", 2) 

177 if len(parts) == 3: 

178 meta = yaml.load(parts[1]) 

179 return (meta if isinstance(meta, dict) else {}), parts[2].lstrip("\n") 

180 return {}, text 

181 

182 

183def render_skill(adapter_text: str, command: str) -> str: 

184 """Render an adapter command markdown as a ``.agents/skills`` SKILL.md (pure). 

185 

186 Lifts the adapter's ``description`` into skill frontmatter (``name: keel-<command>``) and 

187 keeps the full project-neutral body, so non-Claude agents discover and run it as a skill. 

188 """ 

189 meta, body = _split_frontmatter(adapter_text) 

190 desc = " ".join(str(meta.get("description", f"keel {command} workflow")).split()) 

191 name = f"{SKILL_PREFIX}{command}" 

192 front = yaml.dump( 

193 {"name": name, "description": desc}, sort_keys=False, allow_unicode=True, width=10**9 

194 ).strip() 

195 intro = ( 

196 f"Use this skill when the user asks to run the keel command `{command}` " 

197 f"(e.g. `keel {command} ...`, `{command} <args>`, or `/keel:{command}`). It reads every " 

198 f"project value from `.keel/project.yaml` via the `keel` CLI." 

199 ) 

200 return f"---\n{front}\n---\n\n# {name}\n\n{intro}\n\n{body}" 

201 

202 

203def render_legacy_claude_wrapper(legacy_command: str, keel_command: str) -> str: 

204 """Render a native legacy slash-command shim that delegates to ``/keel:<command>``.""" 

205 return ( 

206 f"# /{legacy_command}\n\n" 

207 f"This legacy command is now a thin compatibility wrapper for `/keel:{keel_command}`.\n\n" 

208 "Before doing any mutating work, run:\n\n" 

209 "```bash\n" 

210 f"keel plan .keel/project.yaml --root . --command {keel_command} --live --json\n" 

211 "```\n\n" 

212 f"Then execute `/keel:{keel_command}` with the user's original arguments and flags " 

213 "unchanged. Preserve dry-run, jury/no-jury, review-comment mode, merge behavior, " 

214 "issue targeting, PR targeting, and any project policy exposed by `.keel/project.yaml` " 

215 "or `.keel/extensions/`. Do not duplicate the keel workflow body here; the installed " 

216 f"`/keel:{keel_command}` adapter is the source of truth.\n\n" 

217 "If the plan reports missing consent, unavailable required capabilities, or an " 

218 "unverified migration row, stop and report that blocker instead of guessing.\n" 

219 ) 

220 

221 

222def render_legacy_skill_wrapper(legacy_command: str, keel_command: str) -> str: 

223 """Render a shared skill shim for non-Claude agents that delegates to ``keel-<command>``.""" 

224 name = f"{LEGACY_SKILL_PREFIX}{legacy_command}" 

225 desc = ( 

226 f"Compatibility wrapper for the legacy `{legacy_command}` command; delegates to " 

227 f"`/keel:{keel_command}` and the `keel-{keel_command}` skill without changing flags." 

228 ) 

229 front = yaml.dump( 

230 {"name": name, "description": desc}, sort_keys=False, allow_unicode=True, width=10**9 

231 ).strip() 

232 return ( 

233 f"---\n{front}\n---\n\n" 

234 f"# {name}\n\n" 

235 f"Use this skill when the user asks for the legacy `{legacy_command}` command. " 

236 f"This is a thin compatibility wrapper for the project-neutral `keel-{keel_command}` " 

237 f"skill and `/keel:{keel_command}` command.\n\n" 

238 "1. Preserve the user's original issue or PR target and every flag, including " 

239 "`--dry-run`, jury/no-jury choices, review-comment mode, and merge-mode flags.\n" 

240 "2. Run a live structured preflight before mutating state:\n\n" 

241 "```bash\n" 

242 f"keel plan .keel/project.yaml --root . --command {keel_command} --live --json\n" 

243 "```\n\n" 

244 f"3. Delegate to the `keel-{keel_command}` skill. Do not copy or reinterpret the " 

245 "workflow body in this wrapper.\n\n" 

246 "Stop if consent, capabilities, or parity verification is missing.\n" 

247 ) 

248 

249 

250def parity_ready_commands(matrix_text: str) -> set[str]: 

251 """Return keel command names whose parity-matrix rows are ready for legacy wrappers.""" 

252 ready: set[str] = set() 

253 for line in matrix_text.splitlines(): 

254 stripped = line.strip() 

255 if not stripped.startswith("| `") or "`/keel:" not in stripped: 

256 continue 

257 cells = [cell.strip() for cell in stripped.strip("|").split("|")] 

258 if len(cells) < 3: 

259 continue 

260 match = re.search(r"`/keel:([^`]+)`", cells[1]) 

261 status = cells[2].strip("`") 

262 if match and status in PARITY_READY_STATUSES: 

263 ready.add(match.group(1)) 

264 return ready 

265 

266 

267def _legacy_expected_files( 

268 mappings: dict[str, str], *, _src: Path | None = None 

269) -> dict[str, dict[str, tuple[Path, str, str, str]]]: 

270 src = _src or ADAPTERS 

271 expected: dict[str, dict[str, tuple[Path, str, str, str]]] = {"claude": {}, "skills": {}} 

272 for legacy, command in sorted(mappings.items()): 

273 source = src / f"{command}.md" 

274 source_text = source.read_text(encoding="utf-8") 

275 claude_body = render_legacy_claude_wrapper(legacy, command) 

276 expected["claude"][f"{legacy}.md"] = ( 

277 Path(LEGACY_CLAUDE_DIR) / f"{legacy}.md", 

278 command, 

279 source_text, 

280 claude_body, 

281 ) 

282 skill_name = f"{LEGACY_SKILL_PREFIX}{legacy}" 

283 skill_body = render_legacy_skill_wrapper(legacy, command) 

284 expected["skills"][skill_name] = ( 

285 Path(SKILLS_DIR) / skill_name / "SKILL.md", 

286 command, 

287 source_text, 

288 skill_body, 

289 ) 

290 return expected 

291 

292 

293def default_legacy_mappings(*, _src: Path | None = None) -> dict[str, str]: 

294 """Default one-to-one legacy wrapper mapping for every packaged adapter command.""" 

295 return {Path(name).stem: Path(name).stem for name in adapter_names(_src=_src)} 

296 

297 

298_LEGACY_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") 

299 

300 

301def _validate_legacy_mappings( 

302 mappings: dict[str, str], 

303 *, 

304 ready_commands: set[str] | None, 

305 _src: Path | None, 

306) -> None: 

307 packaged = {Path(name).stem for name in adapter_names(_src=_src)} 

308 for legacy, command in mappings.items(): 

309 if not legacy or not command: 

310 raise ValueError("legacy wrapper mappings must use non-empty command names") 

311 if not _LEGACY_NAME_RE.match(legacy): 

312 raise ValueError(f"invalid legacy wrapper command name: {legacy!r}") 

313 if command not in packaged: 

314 raise ValueError(f"unknown keel command for legacy wrapper: {command}") 

315 if ready_commands is not None and command not in ready_commands: 

316 raise ValueError(f"keel command is not parity-ready for legacy wrapper: {command}") 

317 

318 

319#: The one directory each legacy surface may be written into. 

320_LEGACY_WRITE_ROOTS: dict[str, str] = { 

321 "claude": LEGACY_CLAUDE_DIR, 

322 "skills": SKILLS_DIR, 

323} 

324 

325 

326def _contained_destination(root_path: Path, rel: Path, *, agent: str, name: str) -> Path: 

327 """Resolve ``rel`` under the surface's write root, refusing any escape. 

328 

329 #870's second requirement — "enforce that destination paths resolve strictly 

330 under the target commands directory" — never shipped; only the regex on 

331 legacy names did (#932). Both inputs to ``rel`` are gated today (the name by 

332 that regex, the command against the packaged adapter set), so no traversal 

333 can be constructed right now. This is the layer that keeps that true when a 

334 third input arrives: the write is anchored to a directory rather than 

335 trusting whatever the mapping produced. 

336 

337 Checked after ``resolve()``, so ``..`` segments and symlinked parents are 

338 both accounted for — a check on the unresolved string would pass a path that 

339 the filesystem then walks out of. 

340 """ 

341 base = (root_path / _LEGACY_WRITE_ROOTS[agent]).resolve() 

342 dest = (root_path / rel).resolve() 

343 try: 

344 dest.relative_to(base) 

345 except ValueError: 

346 raise ValueError( 

347 f"legacy wrapper {name!r} resolves outside {_LEGACY_WRITE_ROOTS[agent]}: {dest}" 

348 ) from None 

349 return dest 

350 

351 

352def install_legacy_wrappers( 

353 agent: str, 

354 root: str | Path, 

355 *, 

356 mappings: dict[str, str] | None = None, 

357 ready_commands: set[str] | None = None, 

358 force: bool = False, 

359 _src: Path | None = None, 

360) -> tuple[list[str], list[str]]: 

361 """Install thin legacy compatibility wrappers for one legacy surface.""" 

362 if agent not in LEGACY_TARGETS: 

363 raise KeyError(agent) 

364 wrapper_mappings = mappings or default_legacy_mappings(_src=_src) 

365 _validate_legacy_mappings(wrapper_mappings, ready_commands=ready_commands, _src=_src) 

366 expected = _legacy_expected_files(wrapper_mappings, _src=_src)[agent] 

367 root_path = Path(root) 

368 installed: list[str] = [] 

369 skipped: list[str] = [] 

370 surface = f"legacy-{agent}" 

371 for name, (rel, command, source_text, generated_text) in expected.items(): 

372 dest = _contained_destination(root_path, rel, agent=agent, name=name) 

373 if dest.exists() and not force: 

374 skipped.append(name) 

375 continue 

376 dest.parent.mkdir(parents=True, exist_ok=True) 

377 dest.write_text( 

378 _with_marker(surface, command, source_text, generated_text), encoding="utf-8" 

379 ) 

380 installed.append(name) 

381 return installed, skipped 

382 

383 

384def install_all_legacy_wrappers( 

385 root: str | Path, 

386 *, 

387 mappings: dict[str, str] | None = None, 

388 ready_commands: set[str] | None = None, 

389 force: bool = False, 

390 _src: Path | None = None, 

391) -> dict[str, tuple[list[str], list[str]]]: 

392 """Install legacy compatibility wrappers into both supported discovery surfaces.""" 

393 return { 

394 target: install_legacy_wrappers( 

395 target, 

396 root, 

397 mappings=mappings, 

398 ready_commands=ready_commands, 

399 force=force, 

400 _src=_src, 

401 ) 

402 for target in LEGACY_TARGETS 

403 } 

404 

405 

406def _install_commands( 

407 root: str | Path, *, force: bool, _src: Path | None 

408) -> tuple[list[str], list[str]]: 

409 target = Path(root) / CLAUDE_DIR 

410 src = _src or ADAPTERS 

411 target.mkdir(parents=True, exist_ok=True) 

412 installed: list[str] = [] 

413 skipped: list[str] = [] 

414 for f in sorted(src.glob("*.md")): 

415 dest = target / f.name 

416 if dest.exists() and not force: 

417 skipped.append(f.name) 

418 continue 

419 source_text = f.read_text(encoding="utf-8") 

420 dest.write_text(_with_marker("claude", f.stem, source_text, source_text), encoding="utf-8") 

421 installed.append(f.name) 

422 return installed, skipped 

423 

424 

425def _install_skills( 

426 root: str | Path, *, force: bool, _src: Path | None 

427) -> tuple[list[str], list[str]]: 

428 src = _src or ADAPTERS 

429 base = Path(root) / SKILLS_DIR 

430 installed: list[str] = [] 

431 skipped: list[str] = [] 

432 for f in sorted(src.glob("*.md")): 

433 name = f"{SKILL_PREFIX}{f.stem}" 

434 dest = base / name / "SKILL.md" 

435 if dest.exists() and not force: 

436 skipped.append(name) 

437 continue 

438 dest.parent.mkdir(parents=True, exist_ok=True) 

439 source_text = f.read_text(encoding="utf-8") 

440 rendered = render_skill(source_text, f.stem) 

441 dest.write_text(_with_marker("skills", f.stem, source_text, rendered), encoding="utf-8") 

442 installed.append(name) 

443 return installed, skipped 

444 

445 

446def install( 

447 agent: str, root: str | Path, *, force: bool = False, _src: Path | None = None 

448) -> tuple[list[str], list[str]]: 

449 """Install one surface into ``root``. ``agent`` is ``claude`` or ``skills``. 

450 

451 Returns ``(installed, skipped)`` names. Existing files are skipped unless ``force``. 

452 Raises :class:`KeyError` for an unknown surface. 

453 """ 

454 if agent == "claude": 

455 return _install_commands(root, force=force, _src=_src) 

456 if agent == "skills": 

457 return _install_skills(root, force=force, _src=_src) 

458 raise KeyError(agent) 

459 

460 

461def install_all( 

462 root: str | Path, *, force: bool = False, _src: Path | None = None 

463) -> dict[str, tuple[list[str], list[str]]]: 

464 """Install **both** surfaces (Claude commands + the universal skill set). 

465 

466 Returns ``surface -> (installed, skipped)`` for each entry in :data:`TARGETS`. 

467 """ 

468 return {t: install(t, root, force=force, _src=_src) for t in TARGETS} 

469 

470 

471def plugin_files(*, _src: Path | None = None) -> dict[str, str]: 

472 """Render the committed Claude Code plugin command files (pure). 

473 

474 Returns a mapping of ``commands/<cmd>.md`` → file content, generated **from the same** 

475 ``adapters/commands/*.md`` bodies that drive the ``claude`` install surface. The plugin is 

476 named ``keel``, so each flat command file is discovered as ``/keel:<cmd>`` once the plugin 

477 is installed via ``/plugin install keel``. This is the single source of truth for the 

478 repo-level ``commands/`` directory — the drift test asserts the committed files match. 

479 """ 

480 src = _src or ADAPTERS 

481 out: dict[str, str] = {} 

482 for f in sorted(src.glob("*.md")): 

483 source_text = f.read_text(encoding="utf-8") 

484 rel = f"{PLUGIN_COMMANDS_DIR}/{f.name}" 

485 out[rel] = _with_marker("plugin", f.stem, source_text, source_text) 

486 return out 

487 

488 

489def install_plugin( 

490 root: str | Path, *, force: bool = False, _src: Path | None = None 

491) -> tuple[list[str], list[str]]: 

492 """Write the generated plugin command files into ``root/commands/`` (idempotent). 

493 

494 Used by ``keel install-adapter plugin`` and ``make plugin`` to regenerate the committed 

495 plugin command bodies. Unlike the per-project surfaces, this writes the repo-level plugin 

496 files; ``force`` is unnecessary because the generator is deterministic, but existing files 

497 are overwritten so the committed copy always tracks ``adapters/commands/``. 

498 """ 

499 root_path = Path(root) 

500 installed: list[str] = [] 

501 skipped: list[str] = [] 

502 for rel, content in plugin_files(_src=_src).items(): 

503 dest = root_path / rel 

504 existing = dest.read_text(encoding="utf-8") if dest.exists() else None 

505 if existing == content and not force: 

506 skipped.append(rel) 

507 continue 

508 dest.parent.mkdir(parents=True, exist_ok=True) 

509 dest.write_text(content, encoding="utf-8") 

510 installed.append(rel) 

511 return installed, skipped 

512 

513 

514def hint_flags(hint: str) -> list[str]: 

515 """Split an ``argument-hint`` into the flag chips the site renders (pure). 

516 

517 A hint is a sequence of bracketed groups — ``"[issue numbers...] [--tdd] [--dry-run]"`` — 

518 and each top-level group is one chip. Brackets nested inside a group belong to that group 

519 (they are part of a placeholder, not a chip of their own), text outside any bracket is 

520 prose rather than an argument, and an empty or unclosed group yields nothing. The chips 

521 were hand-typed before this function existed, which is how the ``ship`` card grew a 

522 ``--compound`` chip its own hint had already folded into ``--compound|--profile``. 

523 """ 

524 flags: list[str] = [] 

525 depth = 0 

526 start = 0 

527 for index, char in enumerate(hint): 

528 if char == "[": 

529 if depth == 0: 

530 start = index + 1 

531 depth += 1 

532 elif char == "]" and depth: 

533 depth -= 1 

534 if depth == 0: 

535 chip = hint[start:index].strip() 

536 if chip: 

537 flags.append(chip) 

538 return flags 

539 

540 

541def site_params_entry(adapter_text: str) -> dict[str, object]: 

542 """Render one command's site entry from its adapter frontmatter (pure). 

543 

544 Every published field is derived: ``desc`` is the frontmatter ``description``, ``hint`` is 

545 its ``argument-hint``, and ``flags`` is that hint split into chips. Whitespace is collapsed 

546 so a folded YAML scalar publishes as the one line the site renders. 

547 """ 

548 meta, _body = _split_frontmatter(adapter_text) 

549 hint = " ".join(str(meta.get("argument-hint", "")).split()) 

550 return { 

551 "desc": " ".join(str(meta.get("description", "")).split()), 

552 "hint": hint, 

553 "flags": hint_flags(hint), 

554 } 

555 

556 

557def render_site_params(entries: dict[str, dict[str, object]]) -> str: 

558 """Render ``website/params.js`` from ``command -> entry`` (pure). 

559 

560 The site reads the object by key, so the ordering is free; commands are emitted in the 

561 order given (the caller sorts by adapter filename, the same rule every other generated 

562 surface uses) to keep the output byte-stable. 

563 """ 

564 body = json.dumps(entries, indent=1, ensure_ascii=False) 

565 return f"{SITE_PARAMS_HEADER}\n{SITE_PARAMS_GLOBAL} = {body};\n" 

566 

567 

568def site_params_files(*, _src: Path | None = None) -> dict[str, str]: 

569 """Render the committed site argument surface (``website/params.js``). 

570 

571 Returns a mapping of relative path → file content, generated from the same 

572 ``adapters/commands/*.md`` frontmatter that drives every other surface — so the header 

573 the file has always carried is finally true. A drift test asserts the committed file is 

574 byte-identical to this output. 

575 """ 

576 src = _src or ADAPTERS 

577 entries = { 

578 f.stem: site_params_entry(f.read_text(encoding="utf-8")) for f in sorted(src.glob("*.md")) 

579 } 

580 return {SITE_PARAMS_PATH: render_site_params(entries)} 

581 

582 

583def install_site_params( 

584 root: str | Path, *, force: bool = False, _src: Path | None = None 

585) -> tuple[list[str], list[str]]: 

586 """Write the generated ``website/params.js`` into ``root`` (idempotent). 

587 

588 Used by ``keel install-adapter site`` and ``make site-params``. Like the plugin surface 

589 this writes a repo-level file, not a per-project one, so it is never installed by 

590 ``install-adapter all``. 

591 """ 

592 root_path = Path(root) 

593 installed: list[str] = [] 

594 skipped: list[str] = [] 

595 for rel, content in site_params_files(_src=_src).items(): 

596 dest = root_path / rel 

597 existing = dest.read_text(encoding="utf-8") if dest.exists() else None 

598 if existing == content and not force: 

599 skipped.append(rel) 

600 continue 

601 dest.parent.mkdir(parents=True, exist_ok=True) 

602 dest.write_text(content, encoding="utf-8") 

603 installed.append(rel) 

604 return installed, skipped 

605 

606 

607def adapter_status( 

608 agent: str, root: str | Path, *, _src: Path | None = None 

609) -> dict[str, list[AdapterFileStatus]]: 

610 """Report installed adapter freshness for one surface or ``all`` surfaces.""" 

611 if agent != "all" and agent not in STATUS_TARGETS: 

612 raise KeyError(agent) 

613 targets = STATUS_TARGETS if agent == "all" else (agent,) 

614 root_path = Path(root) 

615 expected = _expected_files(_src) 

616 out: dict[str, list[AdapterFileStatus]] = {} 

617 for surface in targets: 

618 rows: list[AdapterFileStatus] = [] 

619 for name, (rel, _command, source_text, generated_text) in expected[surface].items(): 

620 path = root_path / rel 

621 expected_hash = _sha256(generated_text) 

622 source_hash = _sha256(source_text) 

623 if not path.exists(): 

624 # Legacy claude wrappers are opt-in (``install-legacy-wrappers``). 

625 # An absent wrapper means "not installed", not a defect, so it is 

626 # not reported as ``missing`` — that would flag every project that 

627 # never opted in. Installed legacy wrappers are still freshness-checked. 

628 if surface == "legacy-claude": 

629 continue 

630 rows.append( 

631 AdapterFileStatus( 

632 surface, 

633 name, 

634 str(rel), 

635 "missing", 

636 expected_sha256=expected_hash, 

637 source_sha256=source_hash, 

638 ) 

639 ) 

640 continue 

641 body, marker = _split_marker(path.read_text(encoding="utf-8")) 

642 installed_hash = _sha256(body) 

643 if not marker: 

644 rows.append( 

645 AdapterFileStatus( 

646 surface, 

647 name, 

648 str(rel), 

649 "unknown", 

650 "missing keel-generated marker", 

651 installed_sha256=installed_hash, 

652 expected_sha256=expected_hash, 

653 source_sha256=source_hash, 

654 ) 

655 ) 

656 elif installed_hash != marker.get("generated_sha256"): 

657 rows.append( 

658 AdapterFileStatus( 

659 surface, 

660 name, 

661 str(rel), 

662 "locally-modified", 

663 "generated file changed after install", 

664 installed_sha256=installed_hash, 

665 expected_sha256=expected_hash, 

666 source_sha256=source_hash, 

667 ) 

668 ) 

669 elif marker.get("source_sha256") != source_hash or installed_hash != expected_hash: 

670 rows.append( 

671 AdapterFileStatus( 

672 surface, 

673 name, 

674 str(rel), 

675 "outdated", 

676 "packaged adapter source changed", 

677 installed_sha256=installed_hash, 

678 expected_sha256=expected_hash, 

679 source_sha256=source_hash, 

680 ) 

681 ) 

682 else: 

683 rows.append( 

684 AdapterFileStatus( 

685 surface, 

686 name, 

687 str(rel), 

688 "current", 

689 installed_sha256=installed_hash, 

690 expected_sha256=expected_hash, 

691 source_sha256=source_hash, 

692 ) 

693 ) 

694 out[surface] = rows 

695 return out 

696 

697 

698#: managed surface directories scanned for orphan / unmanaged files. 

699#: each entry is ``(surface, relative-dir, file-glob, recurse)`` where ``recurse`` selects 

700#: ``rglob`` (skill ``SKILL.md`` bodies live one directory deeper) over ``glob``. 

701_ORPHAN_SCAN: tuple[tuple[str, str, str, bool], ...] = ( 

702 ("plugin", PLUGIN_COMMANDS_DIR, "*.md", False), 

703 ("claude", CLAUDE_DIR, "*.md", False), 

704 ("legacy-claude", LEGACY_CLAUDE_DIR, "*.md", False), 

705 ("skills", SKILLS_DIR, "SKILL.md", True), 

706) 

707 

708ORPHAN_STALE_MARKER = "orphan" 

709UNMANAGED_NO_MARKER = "unmanaged" 

710 

711 

712def default_known_commands(*, _src: Path | None = None) -> set[str]: 

713 """The command stems keel currently manages: packaged adapters + default legacy targets. 

714 

715 A surface whose marker ``command=`` is in this set is recognised; anything else carrying a 

716 keel marker is a stale-marker orphan. Pure and deterministic. 

717 """ 

718 packaged = {Path(name).stem for name in adapter_names(_src=_src)} 

719 legacy = set(default_legacy_mappings(_src=_src).values()) 

720 return packaged | legacy 

721 

722 

723def _surface_command_from_name(surface: str, name: str) -> str: 

724 """Best-effort command stem for a marker-less file under a managed surface.""" 

725 stem = Path(name).stem 

726 if surface == "skills": 

727 # skill dirs are ``keel-<cmd>`` / ``source-command-<cmd>``; the file is ``SKILL.md``. 

728 parent = Path(name).parent.name 

729 for prefix in (SKILL_PREFIX, LEGACY_SKILL_PREFIX): 

730 if parent.startswith(prefix): 

731 return parent[len(prefix) :] 

732 return parent 

733 return stem 

734 

735 

736def scan_surface_orphans( 

737 root: str | Path, 

738 *, 

739 known_commands: set[str], 

740 project_only: set[str] | None = None, 

741 include_unmanaged: bool = False, 

742 _src: Path | None = None, 

743) -> list[OrphanFileStatus]: 

744 """Scan managed surface directories for files keel no longer manages (pure, deterministic). 

745 

746 Class (a) — **deterministic**: a file carrying a ``keel-generated`` marker whose 

747 ``command=`` is not in ``known_commands`` is reported as ``orphan (stale-marker)``. 

748 

749 Class (b) — **heuristic, opt-in**: a file with **zero** keel markers is reported as 

750 ``unmanaged (no-marker)`` only when ``include_unmanaged`` is set, and never when its 

751 command stem is declared ``project_only``. 

752 

753 ``known_commands`` is the installed/packaged command set (``adapter_names`` stems plus any 

754 legacy-mapping target stems). The scan only reads on-disk files; it never deletes. 

755 """ 

756 project_only = project_only or set() 

757 root_path = Path(root) 

758 out: list[OrphanFileStatus] = [] 

759 for surface, rel_dir, pattern, recurse in _ORPHAN_SCAN: 

760 base = root_path / rel_dir 

761 if not base.is_dir(): 

762 continue 

763 matches = base.rglob(pattern) if recurse else base.glob(pattern) 

764 for path in sorted(matches): 

765 if not path.is_file(): 

766 continue 

767 name = path.relative_to(base).as_posix() 

768 _body, marker = _split_marker(path.read_text(encoding="utf-8")) 

769 if marker: 

770 command = marker.get("command", "") 

771 if command in known_commands: 

772 continue # a recognised, managed surface — not an orphan. 

773 out.append( 

774 OrphanFileStatus( 

775 surface, 

776 name, 

777 str(path.relative_to(root_path).as_posix()), 

778 ORPHAN_STALE_MARKER, 

779 f"stale-marker: command {command!r} not in installed keel", 

780 command, 

781 ) 

782 ) 

783 continue 

784 # no marker: heuristic, opt-in only. 

785 if not include_unmanaged: 

786 continue 

787 command = _surface_command_from_name(surface, name) 

788 if command in project_only: 

789 continue # declared project-only command — never flagged. 

790 out.append( 

791 OrphanFileStatus( 

792 surface, 

793 name, 

794 str(path.relative_to(root_path).as_posix()), 

795 UNMANAGED_NO_MARKER, 

796 "no-marker: command-like surface not keel-managed", 

797 command, 

798 ) 

799 ) 

800 return out 

801 

802 

803def scan_adapter_markers(root: str | Path) -> list[dict[str, str]]: 

804 """Read the ``keel_version`` markers off every installed adapter surface (pure). 

805 

806 Reuses :data:`_ORPHAN_SCAN` and :func:`_split_marker` so the marker source of 

807 truth is shared with the orphan scan. Returns one entry per marker-bearing 

808 surface: ``surface``, ``name``, ``command``, and ``keel_version`` (the value of 

809 the ``keel_version=`` marker field, or ``""`` when absent). Marker-less files 

810 are skipped. Deterministic and read-only. 

811 """ 

812 root_path = Path(root) 

813 out: list[dict[str, str]] = [] 

814 for surface, rel_dir, pattern, recurse in _ORPHAN_SCAN: 

815 base = root_path / rel_dir 

816 if not base.is_dir(): 

817 continue 

818 matches = base.rglob(pattern) if recurse else base.glob(pattern) 

819 for path in sorted(matches): 

820 if not path.is_file(): 

821 continue 

822 _body, marker = _split_marker(path.read_text(encoding="utf-8")) 

823 if not marker: 

824 continue 

825 out.append( 

826 { 

827 "surface": surface, 

828 "name": path.relative_to(base).as_posix(), 

829 "command": marker.get("command", ""), 

830 "keel_version": marker.get("keel_version", ""), 

831 } 

832 ) 

833 return out 

834 

835 

836def update_adapters( 

837 agent: str, 

838 root: str | Path, 

839 *, 

840 dry_run: bool = False, 

841 _src: Path | None = None, 

842) -> dict[str, list[AdapterFileStatus]]: 

843 """Update generated adapter files that are missing or outdated. 

844 

845 Locally-modified or unknown files are reported and left untouched. 

846 """ 

847 if agent != "all" and agent not in TARGETS: 

848 raise KeyError(agent) 

849 targets = TARGETS if agent == "all" else (agent,) 

850 root_path = Path(root) 

851 expected = _expected_files(_src) 

852 before = adapter_status(agent, root, _src=_src) 

853 updated: dict[str, list[AdapterFileStatus]] = {t: [] for t in targets} 

854 for surface in targets: 

855 rows_by_name = {row.name: row for row in before[surface]} 

856 for name, (rel, command, source_text, generated_text) in expected[surface].items(): 

857 row = rows_by_name[name] 

858 if row.status not in {"missing", "outdated"}: 

859 updated[surface].append(row) 

860 continue 

861 if not dry_run: 

862 path = root_path / rel 

863 path.parent.mkdir(parents=True, exist_ok=True) 

864 path.write_text( 

865 _with_marker(surface, command, source_text, generated_text), encoding="utf-8" 

866 ) 

867 updated[surface].append( 

868 AdapterFileStatus( 

869 surface, 

870 name, 

871 str(rel), 

872 "would-update" if dry_run else "updated", 

873 row.detail, 

874 source_sha256=row.source_sha256, 

875 installed_sha256=row.installed_sha256, 

876 expected_sha256=row.expected_sha256, 

877 ) 

878 ) 

879 return updated