Coverage for src/keel/install.py: 100%
379 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
1"""`keel install-adapter` — install the packaged command adapters into a project.
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 json
20import re
21from dataclasses import dataclass
22from pathlib import Path
24from . import __version__
25from . import yaml_helper as yaml
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"
43#: the committed Codex plugin manifest — same shape, reuses the same ./skills.
44CODEX_PLUGIN_MANIFEST = ".codex-plugin/plugin.json"
46#: the static site's published argument surface, generated from the same frontmatter.
47SITE_PARAMS_PATH = "website/params.js"
48#: the header the generated ``params.js`` opens with. It must contain no ``=``: this
49#: repository's drift checks read the file by splitting on the first one — nothing
50#: under ``website/`` parses it, the browser just runs the assignment.
51SITE_PARAMS_HEADER = (
52 "/* generated from src/keel/adapters/commands frontmatter — do not hand-edit.\n"
53 " Regenerate with `make site-params` (keel install-adapter site --root .). */"
54)
55#: the global the site's renderers read the argument surface from.
56SITE_PARAMS_GLOBAL = "window.KEEL_ARGS"
58#: the logical install surfaces (``all`` fans over these).
59TARGETS: tuple[str, ...] = ("claude", "skills")
60STATUS_TARGETS: tuple[str, ...] = ("claude", "skills", "legacy-claude")
61LEGACY_TARGETS: tuple[str, ...] = ("claude", "skills")
62LEGACY_CLAUDE_DIR = ".claude/commands"
63LEGACY_SKILL_PREFIX = "source-command-"
64PARITY_READY_STATUSES = frozenset({"parity-proven", "deferred"})
66MARKER_RE = re.compile(r"\n?<!-- keel-generated: (?P<meta>[^>]*) -->\n?$")
69@dataclass(frozen=True)
70class OrphanFileStatus:
71 """A file under a managed surface directory that keel does not currently manage.
73 ``category`` is ``"orphan"`` (deterministic, class (a)) or ``"unmanaged"`` (heuristic,
74 class (b)). ``reason`` is a stable reason code; ``command`` is the marker's ``command=``
75 for a stale-marker orphan, or the file stem for a marker-less surface.
76 """
78 surface: str
79 name: str
80 path: str
81 category: str
82 reason: str
83 command: str = ""
85 def as_dict(self) -> dict[str, str]:
86 """Render as JSON-compatible contract data (sorted-stable)."""
87 return {
88 "surface": self.surface,
89 "name": self.name,
90 "path": self.path,
91 "category": self.category,
92 "reason": self.reason,
93 "command": self.command,
94 }
97@dataclass(frozen=True)
98class AdapterFileStatus:
99 surface: str
100 name: str
101 path: str
102 status: str
103 detail: str = ""
104 source_sha256: str = ""
105 installed_sha256: str = ""
106 expected_sha256: str = ""
109def _sha256(text: str) -> str:
110 return hashlib.sha256(text.encode("utf-8")).hexdigest()
113def _marker(surface: str, command: str, source_text: str, generated_text: str) -> str:
114 return (
115 "<!-- keel-generated: "
116 f"surface={surface} command={command} keel_version={__version__} "
117 f"source_sha256={_sha256(source_text)} generated_sha256={_sha256(generated_text)} "
118 "-->"
119 )
122def _with_marker(surface: str, command: str, source_text: str, generated_text: str) -> str:
123 marker = _marker(surface, command, source_text, generated_text)
124 return f"{generated_text.rstrip()}\n\n{marker}\n"
127def _split_marker(text: str) -> tuple[str, dict[str, str]]:
128 match = MARKER_RE.search(text)
129 if not match:
130 return text, {}
131 body = text[: match.start()].rstrip() + "\n"
132 meta: dict[str, str] = {}
133 for part in match.group("meta").split():
134 if "=" in part:
135 key, value = part.split("=", 1)
136 meta[key] = value
137 return body, meta
140def _expected_files(_src: Path | None = None) -> dict[str, dict[str, tuple[Path, str, str, str]]]:
141 src = _src or ADAPTERS
142 expected: dict[str, dict[str, tuple[Path, str, str, str]]] = {
143 "claude": {},
144 "skills": {},
145 "legacy-claude": {},
146 }
147 for f in sorted(src.glob("*.md")):
148 source_text = f.read_text(encoding="utf-8")
149 command = f.stem
150 expected["claude"][f.name] = (
151 Path(CLAUDE_DIR) / f.name,
152 command,
153 source_text,
154 source_text,
155 )
156 expected["skills"][f"{SKILL_PREFIX}{command}"] = (
157 Path(SKILLS_DIR) / f"{SKILL_PREFIX}{command}" / "SKILL.md",
158 command,
159 source_text,
160 render_skill(source_text, command),
161 )
162 expected["legacy-claude"] = _legacy_expected_files(
163 default_legacy_mappings(_src=_src), _src=_src
164 )["claude"]
165 return expected
168def adapter_names(*, _src: Path | None = None) -> list[str]:
169 """The command adapters that ship with keel (e.g. ``ship.md``, ``regression.md``)."""
170 return sorted(p.name for p in (_src or ADAPTERS).glob("*.md"))
173def _split_frontmatter(text: str) -> tuple[dict, str]:
174 """Split ``---`` YAML frontmatter from a markdown body. Returns ``(meta, body)``."""
175 if text.startswith("---"):
176 parts = text.split("---", 2)
177 if len(parts) == 3:
178 meta = yaml.load(parts[1])
179 return (meta if isinstance(meta, dict) else {}), parts[2].lstrip("\n")
180 return {}, text
183def render_skill(adapter_text: str, command: str) -> str:
184 """Render an adapter command markdown as a ``.agents/skills`` SKILL.md (pure).
186 Lifts the adapter's ``description`` into skill frontmatter (``name: keel-<command>``) and
187 keeps the full project-neutral body, so non-Claude agents discover and run it as a skill.
188 """
189 meta, body = _split_frontmatter(adapter_text)
190 desc = " ".join(str(meta.get("description", f"keel {command} workflow")).split())
191 name = f"{SKILL_PREFIX}{command}"
192 front = yaml.dump(
193 {"name": name, "description": desc}, sort_keys=False, allow_unicode=True, width=10**9
194 ).strip()
195 intro = (
196 f"Use this skill when the user asks to run the keel command `{command}` "
197 f"(e.g. `keel {command} ...`, `{command} <args>`, or `/keel:{command}`). It reads every "
198 f"project value from `.keel/project.yaml` via the `keel` CLI."
199 )
200 return f"---\n{front}\n---\n\n# {name}\n\n{intro}\n\n{body}"
203def render_legacy_claude_wrapper(legacy_command: str, keel_command: str) -> str:
204 """Render a native legacy slash-command shim that delegates to ``/keel:<command>``."""
205 return (
206 f"# /{legacy_command}\n\n"
207 f"This legacy command is now a thin compatibility wrapper for `/keel:{keel_command}`.\n\n"
208 "Before doing any mutating work, run:\n\n"
209 "```bash\n"
210 f"keel plan .keel/project.yaml --root . --command {keel_command} --live --json\n"
211 "```\n\n"
212 f"Then execute `/keel:{keel_command}` with the user's original arguments and flags "
213 "unchanged. Preserve dry-run, jury/no-jury, review-comment mode, merge behavior, "
214 "issue targeting, PR targeting, and any project policy exposed by `.keel/project.yaml` "
215 "or `.keel/extensions/`. Do not duplicate the keel workflow body here; the installed "
216 f"`/keel:{keel_command}` adapter is the source of truth.\n\n"
217 "If the plan reports missing consent, unavailable required capabilities, or an "
218 "unverified migration row, stop and report that blocker instead of guessing.\n"
219 )
222def render_legacy_skill_wrapper(legacy_command: str, keel_command: str) -> str:
223 """Render a shared skill shim for non-Claude agents that delegates to ``keel-<command>``."""
224 name = f"{LEGACY_SKILL_PREFIX}{legacy_command}"
225 desc = (
226 f"Compatibility wrapper for the legacy `{legacy_command}` command; delegates to "
227 f"`/keel:{keel_command}` and the `keel-{keel_command}` skill without changing flags."
228 )
229 front = yaml.dump(
230 {"name": name, "description": desc}, sort_keys=False, allow_unicode=True, width=10**9
231 ).strip()
232 return (
233 f"---\n{front}\n---\n\n"
234 f"# {name}\n\n"
235 f"Use this skill when the user asks for the legacy `{legacy_command}` command. "
236 f"This is a thin compatibility wrapper for the project-neutral `keel-{keel_command}` "
237 f"skill and `/keel:{keel_command}` command.\n\n"
238 "1. Preserve the user's original issue or PR target and every flag, including "
239 "`--dry-run`, jury/no-jury choices, review-comment mode, and merge-mode flags.\n"
240 "2. Run a live structured preflight before mutating state:\n\n"
241 "```bash\n"
242 f"keel plan .keel/project.yaml --root . --command {keel_command} --live --json\n"
243 "```\n\n"
244 f"3. Delegate to the `keel-{keel_command}` skill. Do not copy or reinterpret the "
245 "workflow body in this wrapper.\n\n"
246 "Stop if consent, capabilities, or parity verification is missing.\n"
247 )
250def parity_ready_commands(matrix_text: str) -> set[str]:
251 """Return keel command names whose parity-matrix rows are ready for legacy wrappers."""
252 ready: set[str] = set()
253 for line in matrix_text.splitlines():
254 stripped = line.strip()
255 if not stripped.startswith("| `") or "`/keel:" not in stripped:
256 continue
257 cells = [cell.strip() for cell in stripped.strip("|").split("|")]
258 if len(cells) < 3:
259 continue
260 match = re.search(r"`/keel:([^`]+)`", cells[1])
261 status = cells[2].strip("`")
262 if match and status in PARITY_READY_STATUSES:
263 ready.add(match.group(1))
264 return ready
267def _legacy_expected_files(
268 mappings: dict[str, str], *, _src: Path | None = None
269) -> dict[str, dict[str, tuple[Path, str, str, str]]]:
270 src = _src or ADAPTERS
271 expected: dict[str, dict[str, tuple[Path, str, str, str]]] = {"claude": {}, "skills": {}}
272 for legacy, command in sorted(mappings.items()):
273 source = src / f"{command}.md"
274 source_text = source.read_text(encoding="utf-8")
275 claude_body = render_legacy_claude_wrapper(legacy, command)
276 expected["claude"][f"{legacy}.md"] = (
277 Path(LEGACY_CLAUDE_DIR) / f"{legacy}.md",
278 command,
279 source_text,
280 claude_body,
281 )
282 skill_name = f"{LEGACY_SKILL_PREFIX}{legacy}"
283 skill_body = render_legacy_skill_wrapper(legacy, command)
284 expected["skills"][skill_name] = (
285 Path(SKILLS_DIR) / skill_name / "SKILL.md",
286 command,
287 source_text,
288 skill_body,
289 )
290 return expected
293def default_legacy_mappings(*, _src: Path | None = None) -> dict[str, str]:
294 """Default one-to-one legacy wrapper mapping for every packaged adapter command."""
295 return {Path(name).stem: Path(name).stem for name in adapter_names(_src=_src)}
298_LEGACY_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
301def _validate_legacy_mappings(
302 mappings: dict[str, str],
303 *,
304 ready_commands: set[str] | None,
305 _src: Path | None,
306) -> None:
307 packaged = {Path(name).stem for name in adapter_names(_src=_src)}
308 for legacy, command in mappings.items():
309 if not legacy or not command:
310 raise ValueError("legacy wrapper mappings must use non-empty command names")
311 if not _LEGACY_NAME_RE.match(legacy):
312 raise ValueError(f"invalid legacy wrapper command name: {legacy!r}")
313 if command not in packaged:
314 raise ValueError(f"unknown keel command for legacy wrapper: {command}")
315 if ready_commands is not None and command not in ready_commands:
316 raise ValueError(f"keel command is not parity-ready for legacy wrapper: {command}")
319#: The one directory each legacy surface may be written into.
320_LEGACY_WRITE_ROOTS: dict[str, str] = {
321 "claude": LEGACY_CLAUDE_DIR,
322 "skills": SKILLS_DIR,
323}
326def _contained_destination(root_path: Path, rel: Path, *, agent: str, name: str) -> Path:
327 """Resolve ``rel`` under the surface's write root, refusing any escape.
329 #870's second requirement — "enforce that destination paths resolve strictly
330 under the target commands directory" — never shipped; only the regex on
331 legacy names did (#932). Both inputs to ``rel`` are gated today (the name by
332 that regex, the command against the packaged adapter set), so no traversal
333 can be constructed right now. This is the layer that keeps that true when a
334 third input arrives: the write is anchored to a directory rather than
335 trusting whatever the mapping produced.
337 Checked after ``resolve()``, so ``..`` segments and symlinked parents are
338 both accounted for — a check on the unresolved string would pass a path that
339 the filesystem then walks out of.
340 """
341 base = (root_path / _LEGACY_WRITE_ROOTS[agent]).resolve()
342 dest = (root_path / rel).resolve()
343 try:
344 dest.relative_to(base)
345 except ValueError:
346 raise ValueError(
347 f"legacy wrapper {name!r} resolves outside {_LEGACY_WRITE_ROOTS[agent]}: {dest}"
348 ) from None
349 return dest
352def install_legacy_wrappers(
353 agent: str,
354 root: str | Path,
355 *,
356 mappings: dict[str, str] | None = None,
357 ready_commands: set[str] | None = None,
358 force: bool = False,
359 _src: Path | None = None,
360) -> tuple[list[str], list[str]]:
361 """Install thin legacy compatibility wrappers for one legacy surface."""
362 if agent not in LEGACY_TARGETS:
363 raise KeyError(agent)
364 wrapper_mappings = mappings or default_legacy_mappings(_src=_src)
365 _validate_legacy_mappings(wrapper_mappings, ready_commands=ready_commands, _src=_src)
366 expected = _legacy_expected_files(wrapper_mappings, _src=_src)[agent]
367 root_path = Path(root)
368 installed: list[str] = []
369 skipped: list[str] = []
370 surface = f"legacy-{agent}"
371 for name, (rel, command, source_text, generated_text) in expected.items():
372 dest = _contained_destination(root_path, rel, agent=agent, name=name)
373 if dest.exists() and not force:
374 skipped.append(name)
375 continue
376 dest.parent.mkdir(parents=True, exist_ok=True)
377 dest.write_text(
378 _with_marker(surface, command, source_text, generated_text), encoding="utf-8"
379 )
380 installed.append(name)
381 return installed, skipped
384def install_all_legacy_wrappers(
385 root: str | Path,
386 *,
387 mappings: dict[str, str] | None = None,
388 ready_commands: set[str] | None = None,
389 force: bool = False,
390 _src: Path | None = None,
391) -> dict[str, tuple[list[str], list[str]]]:
392 """Install legacy compatibility wrappers into both supported discovery surfaces."""
393 return {
394 target: install_legacy_wrappers(
395 target,
396 root,
397 mappings=mappings,
398 ready_commands=ready_commands,
399 force=force,
400 _src=_src,
401 )
402 for target in LEGACY_TARGETS
403 }
406def _install_commands(
407 root: str | Path, *, force: bool, _src: Path | None
408) -> tuple[list[str], list[str]]:
409 target = Path(root) / CLAUDE_DIR
410 src = _src or ADAPTERS
411 target.mkdir(parents=True, exist_ok=True)
412 installed: list[str] = []
413 skipped: list[str] = []
414 for f in sorted(src.glob("*.md")):
415 dest = target / f.name
416 if dest.exists() and not force:
417 skipped.append(f.name)
418 continue
419 source_text = f.read_text(encoding="utf-8")
420 dest.write_text(_with_marker("claude", f.stem, source_text, source_text), encoding="utf-8")
421 installed.append(f.name)
422 return installed, skipped
425def _install_skills(
426 root: str | Path, *, force: bool, _src: Path | None
427) -> tuple[list[str], list[str]]:
428 src = _src or ADAPTERS
429 base = Path(root) / SKILLS_DIR
430 installed: list[str] = []
431 skipped: list[str] = []
432 for f in sorted(src.glob("*.md")):
433 name = f"{SKILL_PREFIX}{f.stem}"
434 dest = base / name / "SKILL.md"
435 if dest.exists() and not force:
436 skipped.append(name)
437 continue
438 dest.parent.mkdir(parents=True, exist_ok=True)
439 source_text = f.read_text(encoding="utf-8")
440 rendered = render_skill(source_text, f.stem)
441 dest.write_text(_with_marker("skills", f.stem, source_text, rendered), encoding="utf-8")
442 installed.append(name)
443 return installed, skipped
446def install(
447 agent: str, root: str | Path, *, force: bool = False, _src: Path | None = None
448) -> tuple[list[str], list[str]]:
449 """Install one surface into ``root``. ``agent`` is ``claude`` or ``skills``.
451 Returns ``(installed, skipped)`` names. Existing files are skipped unless ``force``.
452 Raises :class:`KeyError` for an unknown surface.
453 """
454 if agent == "claude":
455 return _install_commands(root, force=force, _src=_src)
456 if agent == "skills":
457 return _install_skills(root, force=force, _src=_src)
458 raise KeyError(agent)
461def install_all(
462 root: str | Path, *, force: bool = False, _src: Path | None = None
463) -> dict[str, tuple[list[str], list[str]]]:
464 """Install **both** surfaces (Claude commands + the universal skill set).
466 Returns ``surface -> (installed, skipped)`` for each entry in :data:`TARGETS`.
467 """
468 return {t: install(t, root, force=force, _src=_src) for t in TARGETS}
471def plugin_files(*, _src: Path | None = None) -> dict[str, str]:
472 """Render the committed Claude Code plugin command files (pure).
474 Returns a mapping of ``commands/<cmd>.md`` → file content, generated **from the same**
475 ``adapters/commands/*.md`` bodies that drive the ``claude`` install surface. The plugin is
476 named ``keel``, so each flat command file is discovered as ``/keel:<cmd>`` once the plugin
477 is installed via ``/plugin install keel``. This is the single source of truth for the
478 repo-level ``commands/`` directory — the drift test asserts the committed files match.
479 """
480 src = _src or ADAPTERS
481 out: dict[str, str] = {}
482 for f in sorted(src.glob("*.md")):
483 source_text = f.read_text(encoding="utf-8")
484 rel = f"{PLUGIN_COMMANDS_DIR}/{f.name}"
485 out[rel] = _with_marker("plugin", f.stem, source_text, source_text)
486 return out
489def install_plugin(
490 root: str | Path, *, force: bool = False, _src: Path | None = None
491) -> tuple[list[str], list[str]]:
492 """Write the generated plugin command files into ``root/commands/`` (idempotent).
494 Used by ``keel install-adapter plugin`` and ``make plugin`` to regenerate the committed
495 plugin command bodies. Unlike the per-project surfaces, this writes the repo-level plugin
496 files; ``force`` is unnecessary because the generator is deterministic, but existing files
497 are overwritten so the committed copy always tracks ``adapters/commands/``.
498 """
499 root_path = Path(root)
500 installed: list[str] = []
501 skipped: list[str] = []
502 for rel, content in plugin_files(_src=_src).items():
503 dest = root_path / rel
504 existing = dest.read_text(encoding="utf-8") if dest.exists() else None
505 if existing == content and not force:
506 skipped.append(rel)
507 continue
508 dest.parent.mkdir(parents=True, exist_ok=True)
509 dest.write_text(content, encoding="utf-8")
510 installed.append(rel)
511 return installed, skipped
514def hint_flags(hint: str) -> list[str]:
515 """Split an ``argument-hint`` into the flag chips the site renders (pure).
517 A hint is a sequence of bracketed groups — ``"[issue numbers...] [--tdd] [--dry-run]"`` —
518 and each top-level group is one chip. Brackets nested inside a group belong to that group
519 (they are part of a placeholder, not a chip of their own), text outside any bracket is
520 prose rather than an argument, and an empty or unclosed group yields nothing. The chips
521 were hand-typed before this function existed, which is how the ``ship`` card grew a
522 ``--compound`` chip its own hint had already folded into ``--compound|--profile``.
523 """
524 flags: list[str] = []
525 depth = 0
526 start = 0
527 for index, char in enumerate(hint):
528 if char == "[":
529 if depth == 0:
530 start = index + 1
531 depth += 1
532 elif char == "]" and depth:
533 depth -= 1
534 if depth == 0:
535 chip = hint[start:index].strip()
536 if chip:
537 flags.append(chip)
538 return flags
541def site_params_entry(adapter_text: str) -> dict[str, object]:
542 """Render one command's site entry from its adapter frontmatter (pure).
544 Every published field is derived: ``desc`` is the frontmatter ``description``, ``hint`` is
545 its ``argument-hint``, and ``flags`` is that hint split into chips. Whitespace is collapsed
546 so a folded YAML scalar publishes as the one line the site renders.
547 """
548 meta, _body = _split_frontmatter(adapter_text)
549 hint = " ".join(str(meta.get("argument-hint", "")).split())
550 return {
551 "desc": " ".join(str(meta.get("description", "")).split()),
552 "hint": hint,
553 "flags": hint_flags(hint),
554 }
557def render_site_params(entries: dict[str, dict[str, object]]) -> str:
558 """Render ``website/params.js`` from ``command -> entry`` (pure).
560 The site reads the object by key, so the ordering is free; commands are emitted in the
561 order given (the caller sorts by adapter filename, the same rule every other generated
562 surface uses) to keep the output byte-stable.
563 """
564 body = json.dumps(entries, indent=1, ensure_ascii=False)
565 return f"{SITE_PARAMS_HEADER}\n{SITE_PARAMS_GLOBAL} = {body};\n"
568def site_params_files(*, _src: Path | None = None) -> dict[str, str]:
569 """Render the committed site argument surface (``website/params.js``).
571 Returns a mapping of relative path → file content, generated from the same
572 ``adapters/commands/*.md`` frontmatter that drives every other surface — so the header
573 the file has always carried is finally true. A drift test asserts the committed file is
574 byte-identical to this output.
575 """
576 src = _src or ADAPTERS
577 entries = {
578 f.stem: site_params_entry(f.read_text(encoding="utf-8")) for f in sorted(src.glob("*.md"))
579 }
580 return {SITE_PARAMS_PATH: render_site_params(entries)}
583def install_site_params(
584 root: str | Path, *, force: bool = False, _src: Path | None = None
585) -> tuple[list[str], list[str]]:
586 """Write the generated ``website/params.js`` into ``root`` (idempotent).
588 Used by ``keel install-adapter site`` and ``make site-params``. Like the plugin surface
589 this writes a repo-level file, not a per-project one, so it is never installed by
590 ``install-adapter all``.
591 """
592 root_path = Path(root)
593 installed: list[str] = []
594 skipped: list[str] = []
595 for rel, content in site_params_files(_src=_src).items():
596 dest = root_path / rel
597 existing = dest.read_text(encoding="utf-8") if dest.exists() else None
598 if existing == content and not force:
599 skipped.append(rel)
600 continue
601 dest.parent.mkdir(parents=True, exist_ok=True)
602 dest.write_text(content, encoding="utf-8")
603 installed.append(rel)
604 return installed, skipped
607def adapter_status(
608 agent: str, root: str | Path, *, _src: Path | None = None
609) -> dict[str, list[AdapterFileStatus]]:
610 """Report installed adapter freshness for one surface or ``all`` surfaces."""
611 if agent != "all" and agent not in STATUS_TARGETS:
612 raise KeyError(agent)
613 targets = STATUS_TARGETS if agent == "all" else (agent,)
614 root_path = Path(root)
615 expected = _expected_files(_src)
616 out: dict[str, list[AdapterFileStatus]] = {}
617 for surface in targets:
618 rows: list[AdapterFileStatus] = []
619 for name, (rel, _command, source_text, generated_text) in expected[surface].items():
620 path = root_path / rel
621 expected_hash = _sha256(generated_text)
622 source_hash = _sha256(source_text)
623 if not path.exists():
624 # Legacy claude wrappers are opt-in (``install-legacy-wrappers``).
625 # An absent wrapper means "not installed", not a defect, so it is
626 # not reported as ``missing`` — that would flag every project that
627 # never opted in. Installed legacy wrappers are still freshness-checked.
628 if surface == "legacy-claude":
629 continue
630 rows.append(
631 AdapterFileStatus(
632 surface,
633 name,
634 str(rel),
635 "missing",
636 expected_sha256=expected_hash,
637 source_sha256=source_hash,
638 )
639 )
640 continue
641 body, marker = _split_marker(path.read_text(encoding="utf-8"))
642 installed_hash = _sha256(body)
643 if not marker:
644 rows.append(
645 AdapterFileStatus(
646 surface,
647 name,
648 str(rel),
649 "unknown",
650 "missing keel-generated marker",
651 installed_sha256=installed_hash,
652 expected_sha256=expected_hash,
653 source_sha256=source_hash,
654 )
655 )
656 elif installed_hash != marker.get("generated_sha256"):
657 rows.append(
658 AdapterFileStatus(
659 surface,
660 name,
661 str(rel),
662 "locally-modified",
663 "generated file changed after install",
664 installed_sha256=installed_hash,
665 expected_sha256=expected_hash,
666 source_sha256=source_hash,
667 )
668 )
669 elif marker.get("source_sha256") != source_hash or installed_hash != expected_hash:
670 rows.append(
671 AdapterFileStatus(
672 surface,
673 name,
674 str(rel),
675 "outdated",
676 "packaged adapter source changed",
677 installed_sha256=installed_hash,
678 expected_sha256=expected_hash,
679 source_sha256=source_hash,
680 )
681 )
682 else:
683 rows.append(
684 AdapterFileStatus(
685 surface,
686 name,
687 str(rel),
688 "current",
689 installed_sha256=installed_hash,
690 expected_sha256=expected_hash,
691 source_sha256=source_hash,
692 )
693 )
694 out[surface] = rows
695 return out
698#: managed surface directories scanned for orphan / unmanaged files.
699#: each entry is ``(surface, relative-dir, file-glob, recurse)`` where ``recurse`` selects
700#: ``rglob`` (skill ``SKILL.md`` bodies live one directory deeper) over ``glob``.
701_ORPHAN_SCAN: tuple[tuple[str, str, str, bool], ...] = (
702 ("plugin", PLUGIN_COMMANDS_DIR, "*.md", False),
703 ("claude", CLAUDE_DIR, "*.md", False),
704 ("legacy-claude", LEGACY_CLAUDE_DIR, "*.md", False),
705 ("skills", SKILLS_DIR, "SKILL.md", True),
706)
708ORPHAN_STALE_MARKER = "orphan"
709UNMANAGED_NO_MARKER = "unmanaged"
712def default_known_commands(*, _src: Path | None = None) -> set[str]:
713 """The command stems keel currently manages: packaged adapters + default legacy targets.
715 A surface whose marker ``command=`` is in this set is recognised; anything else carrying a
716 keel marker is a stale-marker orphan. Pure and deterministic.
717 """
718 packaged = {Path(name).stem for name in adapter_names(_src=_src)}
719 legacy = set(default_legacy_mappings(_src=_src).values())
720 return packaged | legacy
723def _surface_command_from_name(surface: str, name: str) -> str:
724 """Best-effort command stem for a marker-less file under a managed surface."""
725 stem = Path(name).stem
726 if surface == "skills":
727 # skill dirs are ``keel-<cmd>`` / ``source-command-<cmd>``; the file is ``SKILL.md``.
728 parent = Path(name).parent.name
729 for prefix in (SKILL_PREFIX, LEGACY_SKILL_PREFIX):
730 if parent.startswith(prefix):
731 return parent[len(prefix) :]
732 return parent
733 return stem
736def scan_surface_orphans(
737 root: str | Path,
738 *,
739 known_commands: set[str],
740 project_only: set[str] | None = None,
741 include_unmanaged: bool = False,
742 _src: Path | None = None,
743) -> list[OrphanFileStatus]:
744 """Scan managed surface directories for files keel no longer manages (pure, deterministic).
746 Class (a) — **deterministic**: a file carrying a ``keel-generated`` marker whose
747 ``command=`` is not in ``known_commands`` is reported as ``orphan (stale-marker)``.
749 Class (b) — **heuristic, opt-in**: a file with **zero** keel markers is reported as
750 ``unmanaged (no-marker)`` only when ``include_unmanaged`` is set, and never when its
751 command stem is declared ``project_only``.
753 ``known_commands`` is the installed/packaged command set (``adapter_names`` stems plus any
754 legacy-mapping target stems). The scan only reads on-disk files; it never deletes.
755 """
756 project_only = project_only or set()
757 root_path = Path(root)
758 out: list[OrphanFileStatus] = []
759 for surface, rel_dir, pattern, recurse in _ORPHAN_SCAN:
760 base = root_path / rel_dir
761 if not base.is_dir():
762 continue
763 matches = base.rglob(pattern) if recurse else base.glob(pattern)
764 for path in sorted(matches):
765 if not path.is_file():
766 continue
767 name = path.relative_to(base).as_posix()
768 _body, marker = _split_marker(path.read_text(encoding="utf-8"))
769 if marker:
770 command = marker.get("command", "")
771 if command in known_commands:
772 continue # a recognised, managed surface — not an orphan.
773 out.append(
774 OrphanFileStatus(
775 surface,
776 name,
777 str(path.relative_to(root_path).as_posix()),
778 ORPHAN_STALE_MARKER,
779 f"stale-marker: command {command!r} not in installed keel",
780 command,
781 )
782 )
783 continue
784 # no marker: heuristic, opt-in only.
785 if not include_unmanaged:
786 continue
787 command = _surface_command_from_name(surface, name)
788 if command in project_only:
789 continue # declared project-only command — never flagged.
790 out.append(
791 OrphanFileStatus(
792 surface,
793 name,
794 str(path.relative_to(root_path).as_posix()),
795 UNMANAGED_NO_MARKER,
796 "no-marker: command-like surface not keel-managed",
797 command,
798 )
799 )
800 return out
803def scan_adapter_markers(root: str | Path) -> list[dict[str, str]]:
804 """Read the ``keel_version`` markers off every installed adapter surface (pure).
806 Reuses :data:`_ORPHAN_SCAN` and :func:`_split_marker` so the marker source of
807 truth is shared with the orphan scan. Returns one entry per marker-bearing
808 surface: ``surface``, ``name``, ``command``, and ``keel_version`` (the value of
809 the ``keel_version=`` marker field, or ``""`` when absent). Marker-less files
810 are skipped. Deterministic and read-only.
811 """
812 root_path = Path(root)
813 out: list[dict[str, str]] = []
814 for surface, rel_dir, pattern, recurse in _ORPHAN_SCAN:
815 base = root_path / rel_dir
816 if not base.is_dir():
817 continue
818 matches = base.rglob(pattern) if recurse else base.glob(pattern)
819 for path in sorted(matches):
820 if not path.is_file():
821 continue
822 _body, marker = _split_marker(path.read_text(encoding="utf-8"))
823 if not marker:
824 continue
825 out.append(
826 {
827 "surface": surface,
828 "name": path.relative_to(base).as_posix(),
829 "command": marker.get("command", ""),
830 "keel_version": marker.get("keel_version", ""),
831 }
832 )
833 return out
836def update_adapters(
837 agent: str,
838 root: str | Path,
839 *,
840 dry_run: bool = False,
841 _src: Path | None = None,
842) -> dict[str, list[AdapterFileStatus]]:
843 """Update generated adapter files that are missing or outdated.
845 Locally-modified or unknown files are reported and left untouched.
846 """
847 if agent != "all" and agent not in TARGETS:
848 raise KeyError(agent)
849 targets = TARGETS if agent == "all" else (agent,)
850 root_path = Path(root)
851 expected = _expected_files(_src)
852 before = adapter_status(agent, root, _src=_src)
853 updated: dict[str, list[AdapterFileStatus]] = {t: [] for t in targets}
854 for surface in targets:
855 rows_by_name = {row.name: row for row in before[surface]}
856 for name, (rel, command, source_text, generated_text) in expected[surface].items():
857 row = rows_by_name[name]
858 if row.status not in {"missing", "outdated"}:
859 updated[surface].append(row)
860 continue
861 if not dry_run:
862 path = root_path / rel
863 path.parent.mkdir(parents=True, exist_ok=True)
864 path.write_text(
865 _with_marker(surface, command, source_text, generated_text), encoding="utf-8"
866 )
867 updated[surface].append(
868 AdapterFileStatus(
869 surface,
870 name,
871 str(rel),
872 "would-update" if dry_run else "updated",
873 row.detail,
874 source_sha256=row.source_sha256,
875 installed_sha256=row.installed_sha256,
876 expected_sha256=row.expected_sha256,
877 )
878 )
879 return updated