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
« 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.
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
23from . import __version__
24from . import yaml_helper as yaml
26ADAPTERS = Path(__file__).parent / "adapters" / "commands"
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-"
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"
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"})
55MARKER_RE = re.compile(r"\n?<!-- keel-generated: (?P<meta>[^>]*) -->\n?$")
58@dataclass(frozen=True)
59class OrphanFileStatus:
60 """A file under a managed surface directory that keel does not currently manage.
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 """
67 surface: str
68 name: str
69 path: str
70 category: str
71 reason: str
72 command: str = ""
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 }
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 = ""
98def _sha256(text: str) -> str:
99 return hashlib.sha256(text.encode("utf-8")).hexdigest()
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 )
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"
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
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
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"))
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
172def render_skill(adapter_text: str, command: str) -> str:
173 """Render an adapter command markdown as a ``.agents/skills`` SKILL.md (pure).
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}"
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 )
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 )
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
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
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)}
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}")
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
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 }
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
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
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``.
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)
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).
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}
421def plugin_files(*, _src: Path | None = None) -> dict[str, str]:
422 """Render the committed Claude Code plugin command files (pure).
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
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).
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
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
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)
530ORPHAN_STALE_MARKER = "orphan"
531UNMANAGED_NO_MARKER = "unmanaged"
534def default_known_commands(*, _src: Path | None = None) -> set[str]:
535 """The command stems keel currently manages: packaged adapters + default legacy targets.
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
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
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).
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)``.
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``.
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
617def scan_adapter_markers(root: str | Path) -> list[dict[str, str]]:
618 """Read the ``keel_version`` markers off every installed adapter surface (pure).
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
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.
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