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

321 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-16 18:07 +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 re 

20from dataclasses import dataclass 

21from pathlib import Path 

22 

23import yaml 

24 

25from . import __version__ 

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 

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

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

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

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

48LEGACY_CLAUDE_DIR = ".claude/commands" 

49LEGACY_SKILL_PREFIX = "source-command-" 

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

51 

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

53 

54 

55@dataclass(frozen=True) 

56class OrphanFileStatus: 

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

58 

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

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

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

62 """ 

63 

64 surface: str 

65 name: str 

66 path: str 

67 category: str 

68 reason: str 

69 command: str = "" 

70 

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

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

73 return { 

74 "surface": self.surface, 

75 "name": self.name, 

76 "path": self.path, 

77 "category": self.category, 

78 "reason": self.reason, 

79 "command": self.command, 

80 } 

81 

82 

83@dataclass(frozen=True) 

84class AdapterFileStatus: 

85 surface: str 

86 name: str 

87 path: str 

88 status: str 

89 detail: str = "" 

90 source_sha256: str = "" 

91 installed_sha256: str = "" 

92 expected_sha256: str = "" 

93 

94 

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

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

97 

98 

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

100 return ( 

101 "<!-- keel-generated: " 

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

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

104 "-->" 

105 ) 

106 

107 

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

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

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

111 

112 

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

114 match = MARKER_RE.search(text) 

115 if not match: 

116 return text, {} 

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

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

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

120 if "=" in part: 

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

122 meta[key] = value 

123 return body, meta 

124 

125 

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

127 src = _src or ADAPTERS 

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

129 "claude": {}, 

130 "skills": {}, 

131 "legacy-claude": {}, 

132 } 

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

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

135 command = f.stem 

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

137 Path(CLAUDE_DIR) / f.name, 

138 command, 

139 source_text, 

140 source_text, 

141 ) 

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

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

144 command, 

145 source_text, 

146 render_skill(source_text, command), 

147 ) 

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

149 default_legacy_mappings(_src=_src), _src=_src 

150 )["claude"] 

151 return expected 

152 

153 

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

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

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

157 

158 

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

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

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

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

163 if len(parts) == 3: 

164 meta = yaml.safe_load(parts[1]) 

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

166 return {}, text 

167 

168 

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

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

171 

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

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

174 """ 

175 meta, body = _split_frontmatter(adapter_text) 

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

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

178 front = yaml.safe_dump({"name": name, "description": desc}, 

179 sort_keys=False, allow_unicode=True, width=10**9).strip() 

180 intro = ( 

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

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

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

184 ) 

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

186 

187 

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

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

190 return ( 

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

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

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

194 "```bash\n" 

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

196 "```\n\n" 

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

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

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

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

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

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

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

204 ) 

205 

206 

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

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

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

210 desc = ( 

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

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

213 ) 

214 front = yaml.safe_dump({"name": name, "description": desc}, 

215 sort_keys=False, allow_unicode=True, width=10**9).strip() 

216 return ( 

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

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

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

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

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

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

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

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

225 "```bash\n" 

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

227 "```\n\n" 

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

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

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

231 ) 

232 

233 

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

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

236 ready: set[str] = set() 

237 for line in matrix_text.splitlines(): 

238 stripped = line.strip() 

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

240 continue 

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

242 if len(cells) < 3: 

243 continue 

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

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

246 if match and status in PARITY_READY_STATUSES: 

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

248 return ready 

249 

250 

251def _legacy_expected_files( 

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

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

254 src = _src or ADAPTERS 

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

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

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

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

259 claude_body = render_legacy_claude_wrapper(legacy, command) 

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

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

262 command, 

263 source_text, 

264 claude_body, 

265 ) 

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

267 skill_body = render_legacy_skill_wrapper(legacy, command) 

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

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

270 command, 

271 source_text, 

272 skill_body, 

273 ) 

274 return expected 

275 

276 

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

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

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

280 

281 

282def _validate_legacy_mappings( 

283 mappings: dict[str, str], 

284 *, 

285 ready_commands: set[str] | None, 

286 _src: Path | None, 

287) -> None: 

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

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

290 if not legacy or not command: 

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

292 if command not in packaged: 

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

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

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

296 

297 

298def install_legacy_wrappers( 

299 agent: str, 

300 root: str | Path, 

301 *, 

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

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

304 force: bool = False, 

305 _src: Path | None = None, 

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

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

308 if agent not in LEGACY_TARGETS: 

309 raise KeyError(agent) 

310 wrapper_mappings = mappings or default_legacy_mappings(_src=_src) 

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

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

313 root_path = Path(root) 

314 installed: list[str] = [] 

315 skipped: list[str] = [] 

316 surface = f"legacy-{agent}" 

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

318 dest = root_path / rel 

319 if dest.exists() and not force: 

320 skipped.append(name) 

321 continue 

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

323 dest.write_text(_with_marker(surface, command, source_text, generated_text), 

324 encoding="utf-8") 

325 installed.append(name) 

326 return installed, skipped 

327 

328 

329def install_all_legacy_wrappers( 

330 root: str | Path, 

331 *, 

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

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

334 force: bool = False, 

335 _src: Path | None = None, 

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

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

338 return { 

339 target: install_legacy_wrappers( 

340 target, 

341 root, 

342 mappings=mappings, 

343 ready_commands=ready_commands, 

344 force=force, 

345 _src=_src, 

346 ) 

347 for target in LEGACY_TARGETS 

348 } 

349 

350 

351def _install_commands( 

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

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

354 target = Path(root) / CLAUDE_DIR 

355 src = _src or ADAPTERS 

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

357 installed: list[str] = [] 

358 skipped: list[str] = [] 

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

360 dest = target / f.name 

361 if dest.exists() and not force: 

362 skipped.append(f.name) 

363 continue 

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

365 dest.write_text(_with_marker("claude", f.stem, source_text, source_text), 

366 encoding="utf-8") 

367 installed.append(f.name) 

368 return installed, skipped 

369 

370 

371def _install_skills( 

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

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

374 src = _src or ADAPTERS 

375 base = Path(root) / SKILLS_DIR 

376 installed: list[str] = [] 

377 skipped: list[str] = [] 

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

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

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

381 if dest.exists() and not force: 

382 skipped.append(name) 

383 continue 

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

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

386 rendered = render_skill(source_text, f.stem) 

387 dest.write_text(_with_marker("skills", f.stem, source_text, rendered), 

388 encoding="utf-8") 

389 installed.append(name) 

390 return installed, skipped 

391 

392 

393def install( 

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

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

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

397 

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

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

400 """ 

401 if agent == "claude": 

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

403 if agent == "skills": 

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

405 raise KeyError(agent) 

406 

407 

408def install_all( 

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

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

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

412 

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

414 """ 

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

416 

417 

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

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

420 

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

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

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

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

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

426 """ 

427 src = _src or ADAPTERS 

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

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

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

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

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

433 return out 

434 

435 

436def install_plugin( 

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

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

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

440 

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

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

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

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

445 """ 

446 root_path = Path(root) 

447 installed: list[str] = [] 

448 skipped: list[str] = [] 

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

450 dest = root_path / rel 

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

452 if existing == content and not force: 

453 skipped.append(rel) 

454 continue 

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

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

457 installed.append(rel) 

458 return installed, skipped 

459 

460 

461def adapter_status( 

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

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

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

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

466 if any(t not in STATUS_TARGETS for t in targets): 

467 raise KeyError(agent) 

468 root_path = Path(root) 

469 expected = _expected_files(_src) 

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

471 for surface in targets: 

472 rows: list[AdapterFileStatus] = [] 

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

474 path = root_path / rel 

475 expected_hash = _sha256(generated_text) 

476 source_hash = _sha256(source_text) 

477 if not path.exists(): 

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

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

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

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

482 if surface == "legacy-claude": 

483 continue 

484 rows.append(AdapterFileStatus(surface, name, str(rel), "missing", 

485 expected_sha256=expected_hash, 

486 source_sha256=source_hash)) 

487 continue 

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

489 installed_hash = _sha256(body) 

490 if not marker: 

491 rows.append(AdapterFileStatus(surface, name, str(rel), "unknown", 

492 "missing keel-generated marker", 

493 installed_sha256=installed_hash, 

494 expected_sha256=expected_hash, 

495 source_sha256=source_hash)) 

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

497 rows.append(AdapterFileStatus(surface, name, str(rel), "locally-modified", 

498 "generated file changed after install", 

499 installed_sha256=installed_hash, 

500 expected_sha256=expected_hash, 

501 source_sha256=source_hash)) 

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

503 rows.append(AdapterFileStatus(surface, name, str(rel), "outdated", 

504 "packaged adapter source changed", 

505 installed_sha256=installed_hash, 

506 expected_sha256=expected_hash, 

507 source_sha256=source_hash)) 

508 else: 

509 rows.append(AdapterFileStatus(surface, name, str(rel), "current", 

510 installed_sha256=installed_hash, 

511 expected_sha256=expected_hash, 

512 source_sha256=source_hash)) 

513 out[surface] = rows 

514 return out 

515 

516 

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

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

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

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

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

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

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

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

525) 

526 

527ORPHAN_STALE_MARKER = "orphan" 

528UNMANAGED_NO_MARKER = "unmanaged" 

529 

530 

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

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

533 

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

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

536 """ 

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

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

539 return packaged | legacy 

540 

541 

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

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

544 stem = Path(name).stem 

545 if surface == "skills": 

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

547 parent = Path(name).parent.name 

548 for prefix in (SKILL_PREFIX, LEGACY_SKILL_PREFIX): 

549 if parent.startswith(prefix): 

550 return parent[len(prefix):] 

551 return parent 

552 return stem 

553 

554 

555def scan_surface_orphans( 

556 root: str | Path, 

557 *, 

558 known_commands: set[str], 

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

560 include_unmanaged: bool = False, 

561 _src: Path | None = None, 

562) -> list[OrphanFileStatus]: 

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

564 

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

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

567 

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

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

570 command stem is declared ``project_only``. 

571 

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

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

574 """ 

575 project_only = project_only or set() 

576 root_path = Path(root) 

577 out: list[OrphanFileStatus] = [] 

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

579 base = root_path / rel_dir 

580 if not base.is_dir(): 

581 continue 

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

583 for path in sorted(matches): 

584 if not path.is_file(): 

585 continue 

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

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

588 if marker: 

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

590 if command in known_commands: 

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

592 out.append(OrphanFileStatus( 

593 surface, name, str(path.relative_to(root_path).as_posix()), 

594 ORPHAN_STALE_MARKER, 

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

596 command, 

597 )) 

598 continue 

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

600 if not include_unmanaged: 

601 continue 

602 command = _surface_command_from_name(surface, name) 

603 if command in project_only: 

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

605 out.append(OrphanFileStatus( 

606 surface, name, str(path.relative_to(root_path).as_posix()), 

607 UNMANAGED_NO_MARKER, 

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

609 command, 

610 )) 

611 return out 

612 

613 

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

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

616 

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

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

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

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

621 are skipped. Deterministic and read-only. 

622 """ 

623 root_path = Path(root) 

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

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

626 base = root_path / rel_dir 

627 if not base.is_dir(): 

628 continue 

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

630 for path in sorted(matches): 

631 if not path.is_file(): 

632 continue 

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

634 if not marker: 

635 continue 

636 out.append({ 

637 "surface": surface, 

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

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

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

641 }) 

642 return out 

643 

644 

645def update_adapters( 

646 agent: str, 

647 root: str | Path, 

648 *, 

649 dry_run: bool = False, 

650 _src: Path | None = None, 

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

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

653 

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

655 """ 

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

657 if any(t not in TARGETS for t in targets): 

658 raise KeyError(agent) 

659 root_path = Path(root) 

660 expected = _expected_files(_src) 

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

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

663 for surface in targets: 

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

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

666 row = rows_by_name[name] 

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

668 updated[surface].append(row) 

669 continue 

670 if not dry_run: 

671 path = root_path / rel 

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

673 path.write_text(_with_marker(surface, command, source_text, generated_text), 

674 encoding="utf-8") 

675 updated[surface].append(AdapterFileStatus(surface, name, str(rel), "would-update" 

676 if dry_run else "updated", 

677 row.detail, 

678 source_sha256=row.source_sha256, 

679 installed_sha256=row.installed_sha256, 

680 expected_sha256=row.expected_sha256)) 

681 return updated