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.
- capdisc/__init__.py +69 -0
- capdisc/base.py +56 -0
- capdisc/catalog/__init__.py +71 -0
- capdisc/catalog/entries.py +104 -0
- capdisc/catalog/types.py +227 -0
- capdisc/discovery.py +278 -0
- capdisc/frontmatter.py +49 -0
- capdisc/hooks/__init__.py +61 -0
- capdisc/hooks/schema.py +216 -0
- capdisc/hooks/types.py +156 -0
- capdisc/html.py +75 -0
- capdisc/mcp_catalog.py +87 -0
- capdisc/mcp_harvest/__init__.py +17 -0
- capdisc/mcp_harvest/auth.py +120 -0
- capdisc/mcp_harvest/cache.py +144 -0
- capdisc/mcp_harvest/config.py +325 -0
- capdisc/mcp_harvest/connect.py +187 -0
- capdisc/mcp_harvest/types.py +29 -0
- capdisc/plugin_catalog.py +308 -0
- capdisc/py.typed +0 -0
- capdisc/report/__init__.py +36 -0
- capdisc/report/__main__.py +6 -0
- capdisc/report/assets.py +138 -0
- capdisc/report/cards.py +109 -0
- capdisc/report/cli.py +35 -0
- capdisc/report/components.py +144 -0
- capdisc/report/harvest.py +150 -0
- capdisc/report/html.py +79 -0
- capdisc/report/models.py +82 -0
- capdisc/report/page.py +129 -0
- capdisc/report/sections.py +154 -0
- capdisc/report/types.py +43 -0
- capdisc/scope/__init__.py +83 -0
- capdisc/scope/inventory/__init__.py +20 -0
- capdisc/scope/inventory/assets.py +54 -0
- capdisc/scope/inventory/capture.py +219 -0
- capdisc/scope/inventory/render.py +149 -0
- capdisc/scope/inventory/roots.py +175 -0
- capdisc/scope/locations.py +330 -0
- capdisc/scope/types.py +154 -0
- capdisc/settings.py +230 -0
- capdisc/tokens.py +48 -0
- claude_code_capabilities-0.1.2.dist-info/METADATA +136 -0
- claude_code_capabilities-0.1.2.dist-info/RECORD +47 -0
- claude_code_capabilities-0.1.2.dist-info/WHEEL +4 -0
- claude_code_capabilities-0.1.2.dist-info/entry_points.txt +2 -0
- 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
|
capdisc/report/models.py
ADDED
|
@@ -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">×</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)
|