claude-code-capabilities 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. capdisc/__init__.py +69 -0
  2. capdisc/base.py +56 -0
  3. capdisc/catalog/__init__.py +71 -0
  4. capdisc/catalog/entries.py +104 -0
  5. capdisc/catalog/types.py +227 -0
  6. capdisc/discovery.py +278 -0
  7. capdisc/frontmatter.py +49 -0
  8. capdisc/hooks/__init__.py +61 -0
  9. capdisc/hooks/schema.py +216 -0
  10. capdisc/hooks/types.py +156 -0
  11. capdisc/html.py +75 -0
  12. capdisc/mcp_catalog.py +87 -0
  13. capdisc/mcp_harvest/__init__.py +17 -0
  14. capdisc/mcp_harvest/auth.py +120 -0
  15. capdisc/mcp_harvest/cache.py +144 -0
  16. capdisc/mcp_harvest/config.py +325 -0
  17. capdisc/mcp_harvest/connect.py +187 -0
  18. capdisc/mcp_harvest/types.py +29 -0
  19. capdisc/plugin_catalog.py +308 -0
  20. capdisc/py.typed +0 -0
  21. capdisc/report/__init__.py +36 -0
  22. capdisc/report/__main__.py +6 -0
  23. capdisc/report/assets.py +138 -0
  24. capdisc/report/cards.py +109 -0
  25. capdisc/report/cli.py +35 -0
  26. capdisc/report/components.py +144 -0
  27. capdisc/report/harvest.py +150 -0
  28. capdisc/report/html.py +79 -0
  29. capdisc/report/models.py +82 -0
  30. capdisc/report/page.py +129 -0
  31. capdisc/report/sections.py +154 -0
  32. capdisc/report/types.py +43 -0
  33. capdisc/scope/__init__.py +83 -0
  34. capdisc/scope/inventory/__init__.py +20 -0
  35. capdisc/scope/inventory/assets.py +54 -0
  36. capdisc/scope/inventory/capture.py +219 -0
  37. capdisc/scope/inventory/render.py +149 -0
  38. capdisc/scope/inventory/roots.py +175 -0
  39. capdisc/scope/locations.py +330 -0
  40. capdisc/scope/types.py +154 -0
  41. capdisc/settings.py +230 -0
  42. capdisc/tokens.py +48 -0
  43. claude_code_capabilities-0.1.2.dist-info/METADATA +136 -0
  44. claude_code_capabilities-0.1.2.dist-info/RECORD +47 -0
  45. claude_code_capabilities-0.1.2.dist-info/WHEEL +4 -0
  46. claude_code_capabilities-0.1.2.dist-info/entry_points.txt +2 -0
  47. claude_code_capabilities-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,144 @@
1
+ """Per-plugin component inventory and token-cost estimation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import TypeAdapter, ValidationError, computed_field
8
+
9
+ from ..base import FrozenModel, InputModel
10
+ from ..catalog import CatalogMcpServer
11
+ from ..frontmatter import read_frontmatter
12
+ from ..html import e
13
+ from ..scope import ArtifactKind, CapturedArtifact, ScopeInventory
14
+ from ..tokens import TokenCount, estimate_tokens
15
+ from .html import group_label
16
+ from .models import IndexedPlugin
17
+ from .types import ComponentCount
18
+
19
+ GroupName = Literal["Skills", "Agents", "Hooks", "MCP servers"]
20
+
21
+
22
+ class _Frontmatter(InputModel):
23
+ """Just the description from an artifact's frontmatter — the part that loads every session."""
24
+
25
+ description: str | None = None
26
+
27
+
28
+ _FRONTMATTER: TypeAdapter[_Frontmatter] = TypeAdapter(_Frontmatter)
29
+
30
+
31
+ def _description_tokens(contents: str) -> int:
32
+ """The always-on cost of one artifact: the tokens of its frontmatter description (what loads
33
+ into every session), or 0 when it declares none."""
34
+ data = read_frontmatter(contents)
35
+ if data is None:
36
+ return 0
37
+ try:
38
+ return estimate_tokens(_FRONTMATTER.validate_python(data).description or "")
39
+ except ValidationError:
40
+ return 0
41
+
42
+
43
+ class ComponentGroup(FrozenModel):
44
+ """One component group a plugin contributes — its count and estimated token cost split into
45
+ always-on (loaded every session) and on-demand (loaded only when a component is invoked)."""
46
+
47
+ name: GroupName
48
+ count: ComponentCount
49
+ always_on_tokens: TokenCount
50
+ on_demand_tokens: TokenCount
51
+
52
+
53
+ class PluginComponents(FrozenModel):
54
+ """A plugin's component inventory: the per-group counts and token estimates."""
55
+
56
+ groups: list[ComponentGroup]
57
+
58
+ @computed_field # type: ignore[prop-decorator]
59
+ @property
60
+ def total_always_on(self) -> TokenCount:
61
+ """Tokens this plugin adds to every session — the sum of its groups' always-on cost."""
62
+ return sum(group.always_on_tokens for group in self.groups)
63
+
64
+ @computed_field # type: ignore[prop-decorator]
65
+ @property
66
+ def total_on_demand(self) -> TokenCount:
67
+ """Tokens loaded only when this plugin's components are invoked."""
68
+ return sum(group.on_demand_tokens for group in self.groups)
69
+
70
+
71
+ def _capture_group(name: GroupName, captures: list[CapturedArtifact]) -> ComponentGroup:
72
+ """A group from captured artifacts: always-on is each one's frontmatter description, on-demand
73
+ is its full body."""
74
+ return ComponentGroup(
75
+ name=name,
76
+ count=len(captures),
77
+ always_on_tokens=sum(_description_tokens(c.contents) for c in captures),
78
+ on_demand_tokens=sum(estimate_tokens(c.contents) for c in captures),
79
+ )
80
+
81
+
82
+ def _mcp_group(servers: list[CatalogMcpServer]) -> ComponentGroup:
83
+ """The MCP-servers group: always-on is each server's tool names (what a deferred-tool setup
84
+ loads), on-demand is each tool's full serialized schema (loaded when fetched)."""
85
+ names = sum(estimate_tokens(" ".join(t.name for t in s.tools)) for s in servers)
86
+ schemas = sum(estimate_tokens(t.model_dump_json()) for s in servers for t in s.tools)
87
+ return ComponentGroup(
88
+ name="MCP servers", count=len(servers), always_on_tokens=names, on_demand_tokens=schemas
89
+ )
90
+
91
+
92
+ def plugin_components(
93
+ plugin: IndexedPlugin, inventory: ScopeInventory, mcp_servers: list[CatalogMcpServer]
94
+ ) -> PluginComponents:
95
+ """The component inventory one plugin contributes, grouped and token-costed.
96
+
97
+ Components are attributed by install directory: a captured artifact belongs to the plugin when
98
+ its path sits under the plugin's install path. Skills and commands fold into one "Skills" group
99
+ (the user-facing grouping); agents and hooks each get their own; MCP servers are the plugin's
100
+ declared servers found in the harvest.
101
+
102
+ Args:
103
+ plugin: The plugin and its install directory.
104
+ inventory: The full disk inventory; its captures are attributed by path.
105
+ mcp_servers: The harvested MCP servers, matched to the plugin's declared refs.
106
+
107
+ Returns:
108
+ The grouped, token-costed component inventory.
109
+ """
110
+ owned = [c for c in inventory.artifacts if plugin.path in c.path.parents]
111
+ skills = [c for c in owned if c.kind in (ArtifactKind.skill, ArtifactKind.command)]
112
+ agents = [c for c in owned if c.kind is ArtifactKind.agent]
113
+ hooks = [c for c in owned if c.kind is ArtifactKind.hook]
114
+ servers = [s for s in mcp_servers if s.ref in plugin.card.mcp_servers]
115
+ return PluginComponents(
116
+ groups=[
117
+ _capture_group("Skills", skills),
118
+ _capture_group("Agents", agents),
119
+ _capture_group("Hooks", hooks),
120
+ _mcp_group(servers),
121
+ ]
122
+ )
123
+
124
+
125
+ def components_block(components: PluginComponents) -> str:
126
+ """The component-cost block appended inside a plugin card: a clean per-group table of counts
127
+ and token estimates, with a totals row."""
128
+ rows = "".join(
129
+ f"<tr><td>{e(g.name)}</td><td class='num'>{e(g.count)}</td>"
130
+ f"<td class='num'>~{e(g.always_on_tokens)}</td>"
131
+ f"<td class='num'>~{e(g.on_demand_tokens)}</td></tr>"
132
+ for g in components.groups
133
+ )
134
+ total = (
135
+ "<tr class='total'><td>Total</td><td class='num'></td>"
136
+ f"<td class='num'>~{e(components.total_always_on)}</td>"
137
+ f"<td class='num'>~{e(components.total_on_demand)}</td></tr>"
138
+ )
139
+ return (
140
+ group_label("context cost") + "<div class='tblwrap'><table class='tbl'><thead><tr>"
141
+ "<th>component</th><th class='num'>count</th>"
142
+ "<th class='num'>tok/session</th><th class='num'>on-demand</th>"
143
+ f"</tr></thead><tbody>{rows}{total}</tbody></table></div>"
144
+ )
@@ -0,0 +1,150 @@
1
+ """The impure harvest: scans this machine into an `EnvironmentReport` and persists it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from ..catalog import CatalogMcpServer
10
+ from ..discovery import BUILTIN_TOOLS, scan_indexed_skills
11
+ from ..mcp_catalog import enumerate_mcp_servers
12
+ from ..mcp_harvest import cache_is_stale, read_mcp_cache, refresh_mcp_cache
13
+ from ..plugin_catalog import enumerate_plugins_with_paths, installed_plugin_dirs
14
+ from ..scope import ScopeInventory, ScopeRoots, default_managed_dir
15
+ from ..settings import get_settings
16
+ from .models import EnvironmentReport, IndexedPlugin, IndexedSkill, McpSource
17
+ from .page import render_environment_html
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ REPORT_JSON_NAME = "discovery-report.json"
22
+ REPORT_HTML_NAME = "discovery-report.html"
23
+
24
+ _MCP_SOURCE_CACHE: McpSource = "tool-enriched cache"
25
+ _MCP_SOURCE_FRESH: McpSource = "fresh harvest"
26
+ _MCP_SOURCE_STALE: McpSource = "stale cache (refresh failed)"
27
+ _MCP_SOURCE_LIVE: McpSource = "live (claude mcp list)"
28
+
29
+
30
+ def _harvest_mcp_servers(oauth: bool = False) -> tuple[list[CatalogMcpServer], McpSource]:
31
+ """Return the MCP server cards and the source they came from.
32
+
33
+ Fresh cache → use it. Stale or missing cache → synchronous refresh (this runs in a
34
+ short-lived CLI process, so a background thread would die before finishing). Refresh
35
+ failure → the stale cache if any, else the unenriched live listing. `oauth` always
36
+ refreshes — the point of passing it is to reach servers the cached run could not.
37
+ """
38
+ cached = read_mcp_cache()
39
+ if cached and not cache_is_stale() and not oauth:
40
+ return cached, _MCP_SOURCE_CACHE
41
+ try:
42
+ return refresh_mcp_cache(oauth=oauth), _MCP_SOURCE_FRESH
43
+ except Exception:
44
+ logger.exception("MCP cache refresh failed")
45
+ if cached:
46
+ return cached, _MCP_SOURCE_STALE
47
+ return enumerate_mcp_servers(), _MCP_SOURCE_LIVE
48
+
49
+
50
+ def build_report(oauth: bool = False) -> EnvironmentReport:
51
+ """Scan this machine's discovery surface into one `EnvironmentReport`.
52
+
53
+ Runs exactly what the runtime runs — scope discovery, the disk inventory, the skill index,
54
+ the built-in tools, the plugin catalog, and the MCP harvest (cache first, live fallback).
55
+
56
+ Args:
57
+ oauth: Allow the interactive OAuth flow for HTTP MCP servers with a pre-registered
58
+ client (settings `mcp_oauth_clients`); forces a fresh harvest. Never set this on
59
+ a background path — it may open a browser.
60
+
61
+ Returns:
62
+ The gathered harvest, ready to render or persist.
63
+ """
64
+ cwd = Path.cwd()
65
+ home = Path.home()
66
+ managed_dir = default_managed_dir()
67
+ plugins_root = get_settings().plugins_root
68
+ plugin_dirs = installed_plugin_dirs(plugins_root)
69
+ roots = ScopeRoots.discover(
70
+ start=cwd,
71
+ home_dir=home,
72
+ managed_dir=managed_dir,
73
+ plugin_dirs=plugin_dirs,
74
+ )
75
+ inventory = ScopeInventory.scan(roots)
76
+ skills = [IndexedSkill(card=card, path=path) for card, path in scan_indexed_skills(roots)]
77
+ plugins = [
78
+ IndexedPlugin(card=card, path=path)
79
+ for card, path in enumerate_plugins_with_paths(plugins_root)
80
+ ]
81
+ mcp_servers, mcp_source = _harvest_mcp_servers(oauth)
82
+ return EnvironmentReport(
83
+ generated_at=datetime.now(UTC),
84
+ cwd=cwd,
85
+ home=home,
86
+ plugins_root=plugins_root,
87
+ managed_dir=managed_dir,
88
+ mcp_source=mcp_source,
89
+ scan_roots=roots.roots,
90
+ plugin_dirs=plugin_dirs,
91
+ inventory=inventory,
92
+ skills=skills,
93
+ builtin_tools=BUILTIN_TOOLS,
94
+ plugins=plugins,
95
+ mcp_servers=mcp_servers,
96
+ )
97
+
98
+
99
+ def write_report(
100
+ report: EnvironmentReport,
101
+ *,
102
+ json_path: Path | None = None,
103
+ html_path: Path | None = None,
104
+ ) -> None:
105
+ """Persist a report as both its JSON snapshot and its rendered HTML.
106
+
107
+ Args:
108
+ report: The report to persist.
109
+ json_path: Destination for the machine-readable snapshot; parent dirs are created.
110
+ Under the settings' `report_dir` when None.
111
+ html_path: Destination for the rendered document; parent dirs are created.
112
+ Under the settings' `report_dir` when None.
113
+ """
114
+ json_path = (
115
+ json_path if json_path is not None else get_settings().report_dir / REPORT_JSON_NAME
116
+ )
117
+ html_path = (
118
+ html_path if html_path is not None else get_settings().report_dir / REPORT_HTML_NAME
119
+ )
120
+ # The report can embed raw scanned file contents (e.g. a hook's command string), so its
121
+ # directory is kept private the same way mcp_harvest.auth.ensure_private_dir locks down
122
+ # the OAuth token store — mkdir(mode=...) alone only applies to a dir it newly creates.
123
+ for parent in {json_path.parent, html_path.parent}:
124
+ parent.mkdir(mode=0o700, parents=True, exist_ok=True)
125
+ parent.chmod(0o700)
126
+ json_path.write_text(report.model_dump_json(indent=2), encoding="utf-8")
127
+ html_path.write_text(render_environment_html(report), encoding="utf-8")
128
+
129
+
130
+ def write_report_on_start() -> EnvironmentReport | None:
131
+ """Build and persist the discovery report to the default paths, best-effort.
132
+
133
+ Called from always-on startup paths, so it never raises or blocks — any failure is logged and
134
+ swallowed. Local writes only; no network, no threads.
135
+
136
+ Returns:
137
+ The built report (so a caller can also stash it, e.g. to serve it), or None when the build
138
+ failed. A write failure is logged but does not affect the return value — the report is
139
+ still returned so a caller can serve it even when the on-disk copy is stale or missing.
140
+ """
141
+ try:
142
+ report = build_report()
143
+ except Exception:
144
+ logger.exception("discovery report generation failed")
145
+ return None
146
+ try:
147
+ write_report(report)
148
+ except Exception:
149
+ logger.exception("discovery report write failed")
150
+ return report
capdisc/report/html.py ADDED
@@ -0,0 +1,79 @@
1
+ """HTML-fragment builders shared by the report's card and section renderers.
2
+
3
+ Every dynamic value passed through `e` is escaped, so callers never need to escape by hand.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from ..html import e, redact_home
9
+ from ..scope import ArtifactKind
10
+
11
+ CLASS_SKILL = "k-skill"
12
+ _CLASS_AGENT = "k-agent"
13
+ _CLASS_COMMAND = "k-command"
14
+ _CLASS_HOOK = "k-hook"
15
+ CLASS_TOOL = "k-tool"
16
+ CLASS_MCP = "k-mcp_server"
17
+
18
+
19
+ def ref_span(text: str) -> str:
20
+ return f'<span class="ref">{e(text)}</span>'
21
+
22
+
23
+ def id_span(text: str) -> str:
24
+ return f'<span class="id">{e(text)}</span>'
25
+
26
+
27
+ def desc_block(text: str) -> str:
28
+ return f'<div class="desc">{e(text)}</div>'
29
+
30
+
31
+ def path_block(value: object) -> str:
32
+ return f'<div class="path">{e(redact_home(value))}</div>'
33
+
34
+
35
+ def row_block(inner: str) -> str:
36
+ return f'<div class="row">{inner}</div>'
37
+
38
+
39
+ def group_label(label: str) -> str:
40
+ return f'<div class="grp">{e(label)}</div>'
41
+
42
+
43
+ def empty_block(label: str) -> str:
44
+ return f'<div class="empty">{e(label)}</div>'
45
+
46
+
47
+ def kv_line(label: str, value: object) -> str:
48
+ return f'<div class="path"><b>{e(label)}:</b> {e(redact_home(value))}</div>'
49
+
50
+
51
+ def stat_block(n: object, label: str) -> str:
52
+ return f'<div class="stat"><div class="n">{e(n)}</div><div class="l">{e(label)}</div></div>'
53
+
54
+
55
+ def tag_chips(items: list[str]) -> str:
56
+ if not items:
57
+ return ""
58
+ chips = "".join(f'<span class="tag">{e(t)}</span>' for t in items)
59
+ return f'<div class="tags">{chips}</div>'
60
+
61
+
62
+ def search_key(*parts: object) -> str:
63
+ return e(" ".join(str(p) for p in parts if p).lower())
64
+
65
+
66
+ def card_block(search: str, inner: str, extra_class: str = "") -> str:
67
+ return f'<div class="card {extra_class}" data-s="{search}">{inner}</div>'
68
+
69
+
70
+ def kind_class(kind: ArtifactKind) -> str:
71
+ match kind:
72
+ case ArtifactKind.skill:
73
+ return CLASS_SKILL
74
+ case ArtifactKind.agent:
75
+ return _CLASS_AGENT
76
+ case ArtifactKind.command:
77
+ return _CLASS_COMMAND
78
+ case ArtifactKind.hook:
79
+ return _CLASS_HOOK
@@ -0,0 +1,82 @@
1
+ """The report's own typed models: the machine-readable snapshot and its indexed-artifact pairs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from ..base import FrozenModel
10
+ from ..catalog import CatalogMcpServer, CatalogPlugin, CatalogSkill, CatalogTool
11
+ from ..scope import ScanRoot, ScopeInventory
12
+
13
+ McpSource = Literal[
14
+ "tool-enriched cache", "fresh harvest", "stale cache (refresh failed)", "live (claude mcp list)"
15
+ ]
16
+
17
+
18
+ class IndexedSkill(FrozenModel):
19
+ """One indexed skill card paired with the SKILL.md path it was loaded from."""
20
+
21
+ card: CatalogSkill
22
+ path: Path
23
+
24
+
25
+ class IndexedPlugin(FrozenModel):
26
+ """One plugin card paired with its install directory — the directory the plugin's bundled
27
+ agents/skills/commands/hooks are captured under, so its components can be attributed to it."""
28
+
29
+ card: CatalogPlugin
30
+ path: Path
31
+
32
+
33
+ class EnvironmentReport(FrozenModel):
34
+ """The full discovery harvest of one machine, composed of the existing typed models. The
35
+ machine-readable snapshot the renderer draws from — round-trips through Pydantic JSON, so it
36
+ can be persisted and served as-is. Effective set and hook events are derived from `inventory`
37
+ at render time, not stored."""
38
+
39
+ generated_at: datetime
40
+ cwd: Path
41
+ home: Path
42
+ plugins_root: Path
43
+ managed_dir: Path | None
44
+ mcp_source: McpSource
45
+ scan_roots: list[ScanRoot]
46
+ plugin_dirs: list[Path]
47
+ inventory: ScopeInventory
48
+ skills: list[IndexedSkill]
49
+ builtin_tools: list[CatalogTool]
50
+ plugins: list[IndexedPlugin]
51
+ mcp_servers: list[CatalogMcpServer]
52
+
53
+ @property
54
+ def skill_count(self) -> int:
55
+ """Not a `computed_field`: it would round-trip into the persisted JSON as an `extra`
56
+ key that `model_validate_json(model_dump_json())` then rejects under `extra="forbid"`,
57
+ and it adds no information beyond `len(skills)` a reader can already see."""
58
+ return len(self.skills)
59
+
60
+ @property
61
+ def tool_count(self) -> int:
62
+ return len(self.builtin_tools)
63
+
64
+ @property
65
+ def plugin_count(self) -> int:
66
+ return len(self.plugins)
67
+
68
+ @property
69
+ def mcp_server_count(self) -> int:
70
+ return len(self.mcp_servers)
71
+
72
+ @property
73
+ def capture_count(self) -> int:
74
+ return len(self.inventory.artifacts)
75
+
76
+ @property
77
+ def hook_config_count(self) -> int:
78
+ return len(self.inventory.hook_configs)
79
+
80
+ @property
81
+ def scan_root_count(self) -> int:
82
+ return len(self.scan_roots)
capdisc/report/page.py ADDED
@@ -0,0 +1,129 @@
1
+ """Page assembly: sections into tabs, tabs into one self-contained HTML document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from ..base import FrozenModel
9
+ from ..html import e, redact_home
10
+ from .assets import SCRIPT, STYLE
11
+ from .models import EnvironmentReport
12
+ from .sections import (
13
+ render_inventory_section,
14
+ render_mcp_section,
15
+ render_overview_section,
16
+ render_plugins_section,
17
+ render_roots_section,
18
+ render_skills_section,
19
+ render_tools_section,
20
+ )
21
+ from .types import SectionCount, SectionId, SectionLabel
22
+
23
+
24
+ class _Section(FrozenModel):
25
+ """One tab: its nav id and label, the rendered body, an optional count, and whether it gets a
26
+ client-side filter box (the overview does not)."""
27
+
28
+ id: SectionId
29
+ label: SectionLabel
30
+ body: str
31
+ count: SectionCount | None = None
32
+ searchable: bool = True
33
+
34
+
35
+ def _nav_button(section: _Section) -> str:
36
+ count = "" if section.count is None else str(section.count)
37
+ return (
38
+ f'<button data-v="{e(section.id)}">{e(section.label)}'
39
+ f'<span class="cnt">{e(count)}</span></button>'
40
+ )
41
+
42
+
43
+ def _section_html(section: _Section) -> str:
44
+ search = (
45
+ f'<input class="search" placeholder="filter {e(section.label.lower())}…">'
46
+ if section.searchable
47
+ else ""
48
+ )
49
+ return f'<section class="section" id="sec-{e(section.id)}">{search}{section.body}</section>'
50
+
51
+
52
+ def _page(sections: list[_Section], generated: datetime, cwd: Path) -> str:
53
+ nav = "".join(_nav_button(s) for s in sections)
54
+ main = "".join(_section_html(s) for s in sections)
55
+ return (
56
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">'
57
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
58
+ "<title>capdisc · discovery report</title>"
59
+ f"<style>{STYLE}</style></head><body>"
60
+ "<header><h1>capdisc · discovery report</h1>"
61
+ '<div class="meta">what the engine harvests from this machine · generated '
62
+ f"<b>{e(generated.isoformat(timespec='seconds'))}</b> · "
63
+ f"<b>{e(redact_home(cwd))}</b></div></header>"
64
+ f'<div class="layout"><nav>{nav}</nav><main>{main}</main></div>'
65
+ '<div id="backdrop"></div>'
66
+ '<div id="drawer"><div class="dhead"><span class="dtitle"></span>'
67
+ '<button class="dclose" aria-label="close">&times;</button></div>'
68
+ '<div class="dbody"></div></div>'
69
+ f"<script>{SCRIPT}</script></body></html>"
70
+ )
71
+
72
+
73
+ def render_environment_html(report: EnvironmentReport) -> str:
74
+ """Render an `EnvironmentReport` as one self-contained HTML document.
75
+
76
+ Pure over the snapshot: every dynamic value comes from `report` and is escaped. Existence of
77
+ each scan root is read from disk as it is rendered.
78
+
79
+ Args:
80
+ report: The gathered harvest, as produced by `build_report`.
81
+
82
+ Returns:
83
+ A complete `<!doctype html>` page with inline styles and script and no external resources.
84
+ """
85
+ sections = [
86
+ _Section(
87
+ id="overview",
88
+ label="Overview",
89
+ body=render_overview_section(report),
90
+ searchable=False,
91
+ ),
92
+ _Section(
93
+ id="roots",
94
+ label="Scan roots",
95
+ body=render_roots_section(report.scan_roots, report.plugin_dirs),
96
+ count=report.scan_root_count,
97
+ ),
98
+ _Section(
99
+ id="inventory",
100
+ label="Disk inventory",
101
+ body=render_inventory_section(report.inventory),
102
+ count=report.capture_count,
103
+ ),
104
+ _Section(
105
+ id="skills",
106
+ label="Skills",
107
+ body=render_skills_section(report.skills),
108
+ count=report.skill_count,
109
+ ),
110
+ _Section(
111
+ id="tools",
112
+ label="Builtin tools",
113
+ body=render_tools_section(report.builtin_tools),
114
+ count=report.tool_count,
115
+ ),
116
+ _Section(
117
+ id="plugins",
118
+ label="Plugins",
119
+ body=render_plugins_section(report.plugins, report.inventory, report.mcp_servers),
120
+ count=report.plugin_count,
121
+ ),
122
+ _Section(
123
+ id="mcp",
124
+ label="MCP servers",
125
+ body=render_mcp_section(report.mcp_servers),
126
+ count=report.mcp_server_count,
127
+ ),
128
+ ]
129
+ return _page(sections, report.generated_at, report.cwd)