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

324 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 12:05 +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 

23from . import __version__ 

24from . import yaml_helper as yaml 

25 

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

27 

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

29CLAUDE_DIR = ".claude/commands/keel" 

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

31SKILLS_DIR = ".agents/skills" 

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

33SKILL_PREFIX = "keel-" 

34 

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

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

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

38PLUGIN_COMMANDS_DIR = "commands" 

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

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

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

42#: the committed Codex plugin manifest — same shape, reuses the same ./skill. 

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

44 

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

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

47_TARGETS_SET = frozenset(TARGETS) 

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

49_STATUS_TARGETS_SET = frozenset(STATUS_TARGETS) 

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

51LEGACY_CLAUDE_DIR = ".claude/commands" 

52LEGACY_SKILL_PREFIX = "source-command-" 

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

54 

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

56 

57 

58@dataclass(frozen=True) 

59class OrphanFileStatus: 

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

61 

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

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

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

65 """ 

66 

67 surface: str 

68 name: str 

69 path: str 

70 category: str 

71 reason: str 

72 command: str = "" 

73 

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

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

76 return { 

77 "surface": self.surface, 

78 "name": self.name, 

79 "path": self.path, 

80 "category": self.category, 

81 "reason": self.reason, 

82 "command": self.command, 

83 } 

84 

85 

86@dataclass(frozen=True) 

87class AdapterFileStatus: 

88 surface: str 

89 name: str 

90 path: str 

91 status: str 

92 detail: str = "" 

93 source_sha256: str = "" 

94 installed_sha256: str = "" 

95 expected_sha256: str = "" 

96 

97 

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

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

100 

101 

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

103 return ( 

104 "<!-- keel-generated: " 

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

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

107 "-->" 

108 ) 

109 

110 

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

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

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

114 

115 

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

117 match = MARKER_RE.search(text) 

118 if not match: 

119 return text, {} 

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

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

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

123 if "=" in part: 

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

125 meta[key] = value 

126 return body, meta 

127 

128 

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

130 src = _src or ADAPTERS 

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

132 "claude": {}, 

133 "skills": {}, 

134 "legacy-claude": {}, 

135 } 

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

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

138 command = f.stem 

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

140 Path(CLAUDE_DIR) / f.name, 

141 command, 

142 source_text, 

143 source_text, 

144 ) 

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

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

147 command, 

148 source_text, 

149 render_skill(source_text, command), 

150 ) 

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

152 default_legacy_mappings(_src=_src), _src=_src 

153 )["claude"] 

154 return expected 

155 

156 

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

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

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

160 

161 

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

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

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

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

166 if len(parts) == 3: 

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

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

169 return {}, text 

170 

171 

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

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

174 

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

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

177 """ 

178 meta, body = _split_frontmatter(adapter_text) 

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

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

181 front = yaml.dump({"name": name, "description": desc}, 

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

183 intro = ( 

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

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

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

187 ) 

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

189 

190 

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

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

193 return ( 

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

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

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

197 "```bash\n" 

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

199 "```\n\n" 

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

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

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

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

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

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

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

207 ) 

208 

209 

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

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

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

213 desc = ( 

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

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

216 ) 

217 front = yaml.dump({"name": name, "description": desc}, 

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

219 return ( 

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

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

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

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

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

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

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

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

228 "```bash\n" 

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

230 "```\n\n" 

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

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

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

234 ) 

235 

236 

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

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

239 ready: set[str] = set() 

240 for line in matrix_text.splitlines(): 

241 stripped = line.strip() 

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

243 continue 

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

245 if len(cells) < 3: 

246 continue 

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

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

249 if match and status in PARITY_READY_STATUSES: 

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

251 return ready 

252 

253 

254def _legacy_expected_files( 

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

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

257 src = _src or ADAPTERS 

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

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

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

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

262 claude_body = render_legacy_claude_wrapper(legacy, command) 

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

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

265 command, 

266 source_text, 

267 claude_body, 

268 ) 

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

270 skill_body = render_legacy_skill_wrapper(legacy, command) 

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

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

273 command, 

274 source_text, 

275 skill_body, 

276 ) 

277 return expected 

278 

279 

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

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

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

283 

284 

285def _validate_legacy_mappings( 

286 mappings: dict[str, str], 

287 *, 

288 ready_commands: set[str] | None, 

289 _src: Path | None, 

290) -> None: 

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

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

293 if not legacy or not command: 

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

295 if command not in packaged: 

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

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

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

299 

300 

301def install_legacy_wrappers( 

302 agent: str, 

303 root: str | Path, 

304 *, 

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

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

307 force: bool = False, 

308 _src: Path | None = None, 

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

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

311 if agent not in LEGACY_TARGETS: 

312 raise KeyError(agent) 

313 wrapper_mappings = mappings or default_legacy_mappings(_src=_src) 

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

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

316 root_path = Path(root) 

317 installed: list[str] = [] 

318 skipped: list[str] = [] 

319 surface = f"legacy-{agent}" 

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

321 dest = root_path / rel 

322 if dest.exists() and not force: 

323 skipped.append(name) 

324 continue 

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

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

327 encoding="utf-8") 

328 installed.append(name) 

329 return installed, skipped 

330 

331 

332def install_all_legacy_wrappers( 

333 root: str | Path, 

334 *, 

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

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

337 force: bool = False, 

338 _src: Path | None = None, 

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

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

341 return { 

342 target: install_legacy_wrappers( 

343 target, 

344 root, 

345 mappings=mappings, 

346 ready_commands=ready_commands, 

347 force=force, 

348 _src=_src, 

349 ) 

350 for target in LEGACY_TARGETS 

351 } 

352 

353 

354def _install_commands( 

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

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

357 target = Path(root) / CLAUDE_DIR 

358 src = _src or ADAPTERS 

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

360 installed: list[str] = [] 

361 skipped: list[str] = [] 

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

363 dest = target / f.name 

364 if dest.exists() and not force: 

365 skipped.append(f.name) 

366 continue 

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

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

369 encoding="utf-8") 

370 installed.append(f.name) 

371 return installed, skipped 

372 

373 

374def _install_skills( 

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

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

377 src = _src or ADAPTERS 

378 base = Path(root) / SKILLS_DIR 

379 installed: list[str] = [] 

380 skipped: list[str] = [] 

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

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

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

384 if dest.exists() and not force: 

385 skipped.append(name) 

386 continue 

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

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

389 rendered = render_skill(source_text, f.stem) 

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

391 encoding="utf-8") 

392 installed.append(name) 

393 return installed, skipped 

394 

395 

396def install( 

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

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

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

400 

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

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

403 """ 

404 if agent == "claude": 

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

406 if agent == "skills": 

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

408 raise KeyError(agent) 

409 

410 

411def install_all( 

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

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

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

415 

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

417 """ 

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

419 

420 

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

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

423 

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

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

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

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

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

429 """ 

430 src = _src or ADAPTERS 

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

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

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

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

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

436 return out 

437 

438 

439def install_plugin( 

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

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

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

443 

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

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

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

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

448 """ 

449 root_path = Path(root) 

450 installed: list[str] = [] 

451 skipped: list[str] = [] 

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

453 dest = root_path / rel 

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

455 if existing == content and not force: 

456 skipped.append(rel) 

457 continue 

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

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

460 installed.append(rel) 

461 return installed, skipped 

462 

463 

464def adapter_status( 

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

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

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

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

469 if not _STATUS_TARGETS_SET.issuperset(targets): 

470 raise KeyError(agent) 

471 root_path = Path(root) 

472 expected = _expected_files(_src) 

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

474 for surface in targets: 

475 rows: list[AdapterFileStatus] = [] 

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

477 path = root_path / rel 

478 expected_hash = _sha256(generated_text) 

479 source_hash = _sha256(source_text) 

480 if not path.exists(): 

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

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

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

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

485 if surface == "legacy-claude": 

486 continue 

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

488 expected_sha256=expected_hash, 

489 source_sha256=source_hash)) 

490 continue 

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

492 installed_hash = _sha256(body) 

493 if not marker: 

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

495 "missing keel-generated marker", 

496 installed_sha256=installed_hash, 

497 expected_sha256=expected_hash, 

498 source_sha256=source_hash)) 

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

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

501 "generated file changed after install", 

502 installed_sha256=installed_hash, 

503 expected_sha256=expected_hash, 

504 source_sha256=source_hash)) 

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

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

507 "packaged adapter source changed", 

508 installed_sha256=installed_hash, 

509 expected_sha256=expected_hash, 

510 source_sha256=source_hash)) 

511 else: 

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

513 installed_sha256=installed_hash, 

514 expected_sha256=expected_hash, 

515 source_sha256=source_hash)) 

516 out[surface] = rows 

517 return out 

518 

519 

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

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

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

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

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

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

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

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

528) 

529 

530ORPHAN_STALE_MARKER = "orphan" 

531UNMANAGED_NO_MARKER = "unmanaged" 

532 

533 

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

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

536 

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

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

539 """ 

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

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

542 return packaged | legacy 

543 

544 

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

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

547 stem = Path(name).stem 

548 if surface == "skills": 

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

550 parent = Path(name).parent.name 

551 for prefix in (SKILL_PREFIX, LEGACY_SKILL_PREFIX): 

552 if parent.startswith(prefix): 

553 return parent[len(prefix):] 

554 return parent 

555 return stem 

556 

557 

558def scan_surface_orphans( 

559 root: str | Path, 

560 *, 

561 known_commands: set[str], 

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

563 include_unmanaged: bool = False, 

564 _src: Path | None = None, 

565) -> list[OrphanFileStatus]: 

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

567 

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

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

570 

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

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

573 command stem is declared ``project_only``. 

574 

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

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

577 """ 

578 project_only = project_only or set() 

579 root_path = Path(root) 

580 out: list[OrphanFileStatus] = [] 

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

582 base = root_path / rel_dir 

583 if not base.is_dir(): 

584 continue 

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

586 for path in sorted(matches): 

587 if not path.is_file(): 

588 continue 

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

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

591 if marker: 

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

593 if command in known_commands: 

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

595 out.append(OrphanFileStatus( 

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

597 ORPHAN_STALE_MARKER, 

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

599 command, 

600 )) 

601 continue 

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

603 if not include_unmanaged: 

604 continue 

605 command = _surface_command_from_name(surface, name) 

606 if command in project_only: 

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

608 out.append(OrphanFileStatus( 

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

610 UNMANAGED_NO_MARKER, 

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

612 command, 

613 )) 

614 return out 

615 

616 

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

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

619 

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

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

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

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

624 are skipped. Deterministic and read-only. 

625 """ 

626 root_path = Path(root) 

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

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

629 base = root_path / rel_dir 

630 if not base.is_dir(): 

631 continue 

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

633 for path in sorted(matches): 

634 if not path.is_file(): 

635 continue 

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

637 if not marker: 

638 continue 

639 out.append({ 

640 "surface": surface, 

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

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

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

644 }) 

645 return out 

646 

647 

648def update_adapters( 

649 agent: str, 

650 root: str | Path, 

651 *, 

652 dry_run: bool = False, 

653 _src: Path | None = None, 

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

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

656 

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

658 """ 

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

660 if not _TARGETS_SET.issuperset(targets): 

661 raise KeyError(agent) 

662 root_path = Path(root) 

663 expected = _expected_files(_src) 

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

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

666 for surface in targets: 

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

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

669 row = rows_by_name[name] 

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

671 updated[surface].append(row) 

672 continue 

673 if not dry_run: 

674 path = root_path / rel 

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

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

677 encoding="utf-8") 

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

679 if dry_run else "updated", 

680 row.detail, 

681 source_sha256=row.source_sha256, 

682 installed_sha256=row.installed_sha256, 

683 expected_sha256=row.expected_sha256)) 

684 return updated