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
« 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.
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):
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.
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"""
16from __future__ import annotations
18import hashlib
19import re
20from dataclasses import dataclass
21from pathlib import Path
23import yaml
25from . import __version__
27ADAPTERS = Path(__file__).parent / "adapters" / "commands"
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-"
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"
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"})
52MARKER_RE = re.compile(r"\n?<!-- keel-generated: (?P<meta>[^>]*) -->\n?$")
55@dataclass(frozen=True)
56class OrphanFileStatus:
57 """A file under a managed surface directory that keel does not currently manage.
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 """
64 surface: str
65 name: str
66 path: str
67 category: str
68 reason: str
69 command: str = ""
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 }
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 = ""
95def _sha256(text: str) -> str:
96 return hashlib.sha256(text.encode("utf-8")).hexdigest()
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 )
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"
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
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
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"))
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
169def render_skill(adapter_text: str, command: str) -> str:
170 """Render an adapter command markdown as a ``.agents/skills`` SKILL.md (pure).
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}"
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 )
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 )
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
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
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)}
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}")
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
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 }
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
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
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``.
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)
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).
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}
418def plugin_files(*, _src: Path | None = None) -> dict[str, str]:
419 """Render the committed Claude Code plugin command files (pure).
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
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).
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
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
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)
527ORPHAN_STALE_MARKER = "orphan"
528UNMANAGED_NO_MARKER = "unmanaged"
531def default_known_commands(*, _src: Path | None = None) -> set[str]:
532 """The command stems keel currently manages: packaged adapters + default legacy targets.
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
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
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).
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)``.
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``.
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
614def scan_adapter_markers(root: str | Path) -> list[dict[str, str]]:
615 """Read the ``keel_version`` markers off every installed adapter surface (pure).
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
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.
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