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,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, TypeAdapter, ValidationError
|
|
6
|
+
|
|
7
|
+
from .base import InputModel
|
|
8
|
+
from .catalog import CatalogPlugin, McpServerRef, PluginRef, SkillRef, Tag, catalog_id
|
|
9
|
+
from .discovery import skill_ref
|
|
10
|
+
from .mcp_catalog import SERVER_REF
|
|
11
|
+
|
|
12
|
+
# one place a plugin name becomes a ref
|
|
13
|
+
_PLUGIN_REF: TypeAdapter[PluginRef] = TypeAdapter(PluginRef)
|
|
14
|
+
_TAG: TypeAdapter[Tag] = TypeAdapter(Tag) # one place a keyword becomes a tag
|
|
15
|
+
_INSTALLED = "installed_plugins.json"
|
|
16
|
+
_MANIFEST = ".claude-plugin/plugin.json"
|
|
17
|
+
_MCP_CONFIG = ".mcp.json"
|
|
18
|
+
_MCP_WRAPPER_KEY = "mcpServers"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _PluginManifest(InputModel):
|
|
22
|
+
"""The `.claude-plugin/plugin.json` of one installed plugin — name, description, keywords."""
|
|
23
|
+
|
|
24
|
+
name: str | None = None
|
|
25
|
+
description: str | None = None
|
|
26
|
+
keywords: list[str] = []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _InstalledEntry(InputModel):
|
|
30
|
+
install_path: Path | None = Field(default=None, alias="installPath")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _InstalledPlugins(InputModel):
|
|
34
|
+
"""The `installed_plugins.json` registry: each `name@marketplace` key maps to its installs."""
|
|
35
|
+
|
|
36
|
+
plugins: dict[str, list[_InstalledEntry]] = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _OpaqueServer(InputModel):
|
|
40
|
+
"""One `.mcp.json` server entry, modeled empty: `InputModel` is `extra="ignore"`, so validation
|
|
41
|
+
parses and drops the command/url/headers/credentials. Only the name (map key) survives."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _McpConfig(InputModel):
|
|
45
|
+
"""The `mcpServers`-wrapped `.mcp.json` shape; the flat shape is that same map at top level."""
|
|
46
|
+
|
|
47
|
+
mcpServers: dict[str, _OpaqueServer] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_INSTALLED_ADAPTER = TypeAdapter(_InstalledPlugins)
|
|
51
|
+
_MANIFEST_ADAPTER = TypeAdapter(_PluginManifest)
|
|
52
|
+
_MCP_WRAPPED_ADAPTER = TypeAdapter(_McpConfig)
|
|
53
|
+
_MCP_FLAT_ADAPTER = TypeAdapter(dict[str, _OpaqueServer])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _first_install_path(installs: list[_InstalledEntry]) -> Path | None:
|
|
57
|
+
"""Pick a plugin's install directory from its registry records.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
installs: A plugin's install records; a registry can list several and some carry no path.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The first record's install directory, or None if none carries one.
|
|
64
|
+
"""
|
|
65
|
+
return next((entry.install_path for entry in installs if entry.install_path), None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _skill_refs(install_path: Path) -> list[SkillRef]:
|
|
69
|
+
"""Derive the skill refs a plugin bundles from its `skills/*/SKILL.md` files.
|
|
70
|
+
|
|
71
|
+
Derived the same way the skill index derives them, so these refs join to the plugin's
|
|
72
|
+
`CatalogSkill` entries.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
install_path: The plugin's install directory; its `skills/` tree is walked.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The bundled skill refs, de-duplicated in first-seen order.
|
|
79
|
+
"""
|
|
80
|
+
refs: list[SkillRef] = []
|
|
81
|
+
for skill_md in sorted((install_path / "skills").rglob("SKILL.md")):
|
|
82
|
+
ref = skill_ref(skill_md)
|
|
83
|
+
if ref and ref not in refs:
|
|
84
|
+
refs.append(ref)
|
|
85
|
+
return refs
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _mcp_refs(install_path: Path) -> list[McpServerRef]:
|
|
89
|
+
"""Derive the MCP server refs a plugin declares in its `.mcp.json`.
|
|
90
|
+
|
|
91
|
+
Only the server names (keys) are kept; `_OpaqueServer` drops the values, so a
|
|
92
|
+
command/URL/credential never lands on a card. The file comes in two shapes — a `mcpServers`
|
|
93
|
+
wrapper or the servers map at the top level — so the wrapper is tried first, falling back to
|
|
94
|
+
the flat map.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
install_path: The plugin's install directory holding `.mcp.json`.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The declared server refs, de-duplicated in first-seen order; `[]` if the file is absent,
|
|
101
|
+
unparseable, or holds no valid names.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
raw = (install_path / _MCP_CONFIG).read_text(encoding="utf-8")
|
|
105
|
+
except OSError:
|
|
106
|
+
return []
|
|
107
|
+
try:
|
|
108
|
+
servers = _MCP_WRAPPED_ADAPTER.validate_json(
|
|
109
|
+
raw
|
|
110
|
+
).mcpServers or _MCP_FLAT_ADAPTER.validate_json(raw)
|
|
111
|
+
except ValidationError:
|
|
112
|
+
return []
|
|
113
|
+
refs: list[McpServerRef] = []
|
|
114
|
+
for name in servers:
|
|
115
|
+
if name == _MCP_WRAPPER_KEY:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
ref = SERVER_REF.validate_python(name)
|
|
119
|
+
except ValidationError:
|
|
120
|
+
continue
|
|
121
|
+
if ref not in refs:
|
|
122
|
+
refs.append(ref)
|
|
123
|
+
return refs
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _tags(keywords: list[str]) -> list[Tag]:
|
|
127
|
+
"""Fold a plugin's keywords into valid `Tag`s for lexical recall.
|
|
128
|
+
|
|
129
|
+
Each keyword goes through the `Tag` normalizer, the one slug owner.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
keywords: The plugin's declared keywords.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The normalized tags, de-duplicated in first-seen order, dropping any that don't survive
|
|
136
|
+
slugging.
|
|
137
|
+
"""
|
|
138
|
+
out: list[Tag] = []
|
|
139
|
+
for kw in keywords:
|
|
140
|
+
try:
|
|
141
|
+
tag = _TAG.validate_python(kw)
|
|
142
|
+
except ValidationError:
|
|
143
|
+
continue
|
|
144
|
+
if tag not in out:
|
|
145
|
+
out.append(tag)
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _plugin_card(
|
|
150
|
+
plugin_key: str, manifest: _PluginManifest, install_path: Path
|
|
151
|
+
) -> CatalogPlugin | None:
|
|
152
|
+
"""Build one plugin card, including the skills and MCP servers it bundles.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
plugin_key: The registry key `name@marketplace`; the marketplace suffix is dropped (and
|
|
156
|
+
the `name` field preferred over it) so the ref is the bare plugin name.
|
|
157
|
+
manifest: The plugin's parsed manifest; a plugin with no description is skipped.
|
|
158
|
+
install_path: The plugin's install directory, scanned for bundled skills and servers.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The card, or None when the manifest has no description or the fields don't validate.
|
|
162
|
+
"""
|
|
163
|
+
if not manifest.description:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
ref = _PLUGIN_REF.validate_python(manifest.name or plugin_key.split("@", 1)[0])
|
|
167
|
+
return CatalogPlugin(
|
|
168
|
+
id=catalog_id("plugin", ref),
|
|
169
|
+
ref=ref,
|
|
170
|
+
description=manifest.description,
|
|
171
|
+
tags=_tags(manifest.keywords),
|
|
172
|
+
skills=_skill_refs(install_path),
|
|
173
|
+
mcp_servers=_mcp_refs(install_path),
|
|
174
|
+
)
|
|
175
|
+
except ValidationError:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _read_manifest(install_path: Path) -> _PluginManifest | None:
|
|
180
|
+
"""Read one plugin's `.claude-plugin/plugin.json` manifest.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
install_path: The plugin's install directory.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The parsed manifest, or None when the file is missing or unparseable.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
raw = (install_path / _MANIFEST).read_text(encoding="utf-8")
|
|
190
|
+
except OSError:
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
return _MANIFEST_ADAPTER.validate_json(raw)
|
|
194
|
+
except ValidationError:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_registry(plugins_root: Path) -> _InstalledPlugins | None:
|
|
199
|
+
"""Read the installed-plugins registry.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
plugins_root: Root holding `installed_plugins.json`.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The parsed registry, or None when the file is missing or unparseable.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
raw = (plugins_root / _INSTALLED).read_text(encoding="utf-8")
|
|
209
|
+
except OSError:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
return _INSTALLED_ADAPTER.validate_json(raw)
|
|
213
|
+
except ValidationError:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def installed_plugins(plugins_root: Path) -> dict[str, Path]:
|
|
218
|
+
"""Map each installed plugin to its install directory.
|
|
219
|
+
|
|
220
|
+
The source for reading a plugin's bundled config (e.g. its `.mcp.json`).
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
plugins_root: Root holding `installed_plugins.json`.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
`{name@marketplace: install_dir}`, omitting plugins whose records carry no path; `{}`
|
|
227
|
+
when the registry is missing.
|
|
228
|
+
"""
|
|
229
|
+
registry = _read_registry(plugins_root)
|
|
230
|
+
if registry is None:
|
|
231
|
+
return {}
|
|
232
|
+
found: dict[str, Path] = {}
|
|
233
|
+
for key, installs in registry.plugins.items():
|
|
234
|
+
install_path = _first_install_path(installs)
|
|
235
|
+
if install_path is not None:
|
|
236
|
+
found[key] = install_path
|
|
237
|
+
return found
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def installed_plugin_dirs(plugins_root: Path) -> list[Path]:
|
|
241
|
+
"""List every plugin install directory, for `ScopeRoots.discover(plugin_dirs=…)`.
|
|
242
|
+
|
|
243
|
+
Feeds the scan so plugin-bundled skills are indexed. These are scan roots only — never
|
|
244
|
+
indexed into a card, since an install path is a local absolute path.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
plugins_root: Root holding `installed_plugins.json`.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
The install directories, de-duplicated in first-seen order.
|
|
251
|
+
"""
|
|
252
|
+
seen: list[Path] = []
|
|
253
|
+
for path in installed_plugins(plugins_root).values():
|
|
254
|
+
if path not in seen:
|
|
255
|
+
seen.append(path)
|
|
256
|
+
return seen
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def enumerate_plugins_with_paths(plugins_root: Path) -> list[tuple[CatalogPlugin, Path]]:
|
|
260
|
+
"""Enumerate installed plugins as catalog cards, paired with the install directory each was
|
|
261
|
+
built from.
|
|
262
|
+
|
|
263
|
+
The install path a card actually came from — not re-derived later by matching the card's
|
|
264
|
+
ref back against the registry keys, which can mismatch (and silently drop the plugin) when a
|
|
265
|
+
plugin's manifest `name` differs from its registry key.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
plugins_root: Root holding `installed_plugins.json`.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
One `(card, install_path)` per plugin with a usable manifest, de-duplicated by id; `[]`
|
|
272
|
+
if the registry is missing or unreadable.
|
|
273
|
+
"""
|
|
274
|
+
registry = _read_registry(plugins_root)
|
|
275
|
+
if registry is None:
|
|
276
|
+
return []
|
|
277
|
+
|
|
278
|
+
cards: list[tuple[CatalogPlugin, Path]] = []
|
|
279
|
+
seen: set[str] = set()
|
|
280
|
+
for plugin_key, installs in registry.plugins.items():
|
|
281
|
+
install_path = _first_install_path(installs)
|
|
282
|
+
if install_path is None:
|
|
283
|
+
continue
|
|
284
|
+
manifest = _read_manifest(install_path)
|
|
285
|
+
if manifest is None:
|
|
286
|
+
continue
|
|
287
|
+
card = _plugin_card(plugin_key, manifest, install_path)
|
|
288
|
+
# dedupe by id, not ref: two distinct refs can still truncate to the same catalog_id
|
|
289
|
+
if card is None or card.id in seen:
|
|
290
|
+
continue
|
|
291
|
+
seen.add(card.id)
|
|
292
|
+
cards.append((card, install_path))
|
|
293
|
+
return cards
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def enumerate_plugins(plugins_root: Path) -> list[CatalogPlugin]:
|
|
297
|
+
"""Enumerate installed plugins as catalog cards.
|
|
298
|
+
|
|
299
|
+
Reads each plugin's manifest for its description and keywords. Never executes plugin code.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
plugins_root: Root holding `installed_plugins.json`.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
One card per plugin with a usable manifest, de-duplicated by ref; `[]` if the registry
|
|
306
|
+
is missing or unreadable.
|
|
307
|
+
"""
|
|
308
|
+
return [card for card, _ in enumerate_plugins_with_paths(plugins_root)]
|
capdisc/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Render the full discovery harvest of this machine as one self-contained HTML document.
|
|
2
|
+
|
|
3
|
+
A developer tool for eyeballing what the discovery layer aggregates — scan roots, the on-disk
|
|
4
|
+
scope inventory, indexed skills, built-in tools, installed plugins, and connected MCP servers —
|
|
5
|
+
not a product feature. `build_report` scans the machine into an `EnvironmentReport` (the
|
|
6
|
+
machine-readable snapshot, which round-trips through Pydantic JSON); `render_environment_html`
|
|
7
|
+
renders that snapshot to HTML. `write_report` persists both; `python -m
|
|
8
|
+
capdisc.report` writes them to the default paths.
|
|
9
|
+
|
|
10
|
+
All markup is built here in Python and every dynamic value is escaped; the inline JS only toggles
|
|
11
|
+
classes and `style.display` and reads input values, so the page renders untrusted paths and file
|
|
12
|
+
contents safely.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .cli import main
|
|
18
|
+
from .components import ComponentGroup, PluginComponents, plugin_components
|
|
19
|
+
from .harvest import build_report, write_report, write_report_on_start
|
|
20
|
+
from .models import EnvironmentReport, IndexedPlugin, IndexedSkill, McpSource
|
|
21
|
+
from .page import render_environment_html
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ComponentGroup",
|
|
25
|
+
"EnvironmentReport",
|
|
26
|
+
"IndexedPlugin",
|
|
27
|
+
"IndexedSkill",
|
|
28
|
+
"McpSource",
|
|
29
|
+
"PluginComponents",
|
|
30
|
+
"build_report",
|
|
31
|
+
"main",
|
|
32
|
+
"plugin_components",
|
|
33
|
+
"render_environment_html",
|
|
34
|
+
"write_report",
|
|
35
|
+
"write_report_on_start",
|
|
36
|
+
]
|
capdisc/report/assets.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Inline CSS and JS for the self-contained HTML report — no external resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
STYLE = """
|
|
6
|
+
:root{--bg:#0d1117;--panel:#161b22;--panel2:#1c2430;--bd:#2d3440;--fg:#e6edf3;--mut:#8b949e;
|
|
7
|
+
--ac:#58a6ff;--gn:#3fb950;--yl:#d29922;--pu:#bc8cff;--cy:#39c5cf;}
|
|
8
|
+
*{box-sizing:border-box}
|
|
9
|
+
body{margin:0;background:var(--bg);color:var(--fg);
|
|
10
|
+
font:14px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,sans-serif}
|
|
11
|
+
code,.mono{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace}
|
|
12
|
+
header{padding:20px 24px;border-bottom:1px solid var(--bd);background:var(--panel)}
|
|
13
|
+
h1{margin:0 0 4px;font-size:20px}
|
|
14
|
+
.meta{color:var(--mut);font-size:12px}.meta b{color:var(--fg)}
|
|
15
|
+
.layout{display:flex;min-height:calc(100vh - 80px)}
|
|
16
|
+
nav{width:220px;flex:none;border-right:1px solid var(--bd);background:var(--panel);padding:12px;
|
|
17
|
+
position:sticky;top:0;align-self:flex-start;height:calc(100vh - 80px);overflow:auto}
|
|
18
|
+
nav button{display:flex;justify-content:space-between;width:100%;text-align:left;background:none;
|
|
19
|
+
border:none;color:var(--fg);padding:9px 11px;border-radius:7px;cursor:pointer;font-size:13px;
|
|
20
|
+
margin-bottom:2px}
|
|
21
|
+
nav button:hover{background:var(--panel2)}
|
|
22
|
+
nav button.active{background:var(--ac);color:#04111f;font-weight:600}
|
|
23
|
+
nav .cnt{color:var(--mut);font-size:11px}nav button.active .cnt{color:#04111f}
|
|
24
|
+
main{flex:1;padding:20px 24px;overflow:auto}
|
|
25
|
+
.section{display:none}.section.active{display:block}
|
|
26
|
+
.search{width:100%;max-width:480px;padding:9px 12px;background:var(--panel2);
|
|
27
|
+
border:1px solid var(--bd);border-radius:8px;color:var(--fg);font-size:13px;margin-bottom:16px}
|
|
28
|
+
.search:focus{outline:none;border-color:var(--ac)}
|
|
29
|
+
.card{background:var(--panel);border:1px solid var(--bd);border-radius:10px;
|
|
30
|
+
padding:14px 16px;margin-bottom:10px}
|
|
31
|
+
.card.eff{border-left:3px solid var(--gn)}.card.shadow{opacity:.62}
|
|
32
|
+
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
33
|
+
.ref{font-weight:600;font-size:14px}.id{color:var(--mut);font-size:11px}
|
|
34
|
+
.desc{color:#c9d1d9;margin:7px 0 0;font-size:13px}
|
|
35
|
+
.path{color:var(--mut);font-size:11.5px;margin-top:6px;word-break:break-all}
|
|
36
|
+
.tags{margin-top:8px;display:flex;gap:5px;flex-wrap:wrap}
|
|
37
|
+
.tag{background:var(--panel2);border:1px solid var(--bd);border-radius:20px;padding:1px 9px;
|
|
38
|
+
font-size:11px;color:var(--cy)}
|
|
39
|
+
.pill{font-size:10.5px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;
|
|
40
|
+
padding:2px 8px;border-radius:5px}
|
|
41
|
+
.k-skill{background:#1f2d3d;color:var(--ac)}.k-agent{background:#2d1f3d;color:var(--pu)}
|
|
42
|
+
.k-command{background:#1f3d2a;color:var(--gn)}.k-hook{background:#3d2f1f;color:var(--yl)}
|
|
43
|
+
.k-tool{background:#3d1f2a;color:#ff7b9c}.k-mcp_server{background:#1f3a3d;color:var(--cy)}
|
|
44
|
+
.sc{background:var(--panel2);border:1px solid var(--bd);color:var(--mut)}
|
|
45
|
+
.eff-b{background:#16341f;color:var(--gn)}
|
|
46
|
+
.toolrow{font-size:12px;padding:6px 0;border-top:1px solid var(--bd)}
|
|
47
|
+
.toolrow:first-child{border-top:none}
|
|
48
|
+
.toolname{color:var(--cy);font-weight:600}.params{color:var(--mut)}
|
|
49
|
+
.preview{margin-top:8px;background:var(--bg);border:1px solid var(--bd);border-radius:6px;
|
|
50
|
+
padding:8px 10px;color:var(--mut);font-size:11.5px;white-space:pre-wrap;max-height:160px;
|
|
51
|
+
overflow:auto;display:none}
|
|
52
|
+
.card.open .preview{display:block}
|
|
53
|
+
.toggle{cursor:pointer;color:var(--ac);font-size:11px;background:none;border:none;padding:0;margin-top:6px}
|
|
54
|
+
.bigstat{display:flex;gap:14px;flex-wrap:wrap;margin-bottom:18px}
|
|
55
|
+
.stat{background:var(--panel);border:1px solid var(--bd);border-radius:10px;padding:12px 16px;
|
|
56
|
+
min-width:110px}
|
|
57
|
+
.stat .n{font-size:24px;font-weight:700}.stat .l{color:var(--mut);font-size:12px}
|
|
58
|
+
.grp{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin:18px 0 8px}
|
|
59
|
+
.empty{color:var(--mut);padding:24px;text-align:center}
|
|
60
|
+
.eventbar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
|
|
61
|
+
.event{background:#3d2f1f;color:var(--yl);border-radius:5px;padding:2px 9px;font-size:11.5px}
|
|
62
|
+
.tblwrap{overflow-x:auto;margin-top:8px;-webkit-overflow-scrolling:touch}
|
|
63
|
+
table.tbl{width:100%;border-collapse:collapse;font-size:12px}
|
|
64
|
+
table.tbl th,table.tbl td{text-align:left;padding:5px 9px;border-bottom:1px solid var(--bd);
|
|
65
|
+
vertical-align:top}
|
|
66
|
+
table.tbl th{color:var(--mut);font-weight:600;font-size:10.5px;text-transform:uppercase;
|
|
67
|
+
letter-spacing:.4px;white-space:nowrap}
|
|
68
|
+
table.tbl td.num,table.tbl th.num{text-align:right;font-variant-numeric:tabular-nums;
|
|
69
|
+
white-space:nowrap}
|
|
70
|
+
table.tbl tr:last-child td{border-bottom:none}
|
|
71
|
+
table.tbl tr.total td{border-top:1px solid var(--bd);font-weight:700;color:var(--fg)}
|
|
72
|
+
table.tbl td.toolname{color:var(--cy);font-weight:600;white-space:nowrap}
|
|
73
|
+
.more{cursor:pointer;color:var(--ac);font-size:11.5px;background:none;border:1px solid var(--bd);
|
|
74
|
+
border-radius:6px;padding:3px 11px;margin-top:9px}
|
|
75
|
+
.more:hover{border-color:var(--ac)}
|
|
76
|
+
.popup-src{display:none}
|
|
77
|
+
#backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;z-index:40}
|
|
78
|
+
#backdrop.open{display:block}
|
|
79
|
+
#drawer{position:fixed;left:0;right:0;bottom:0;max-height:72vh;background:var(--panel);
|
|
80
|
+
border-top:1px solid var(--bd);border-radius:14px 14px 0 0;box-shadow:0 -10px 34px rgba(0,0,0,.5);
|
|
81
|
+
transform:translateY(101%);transition:transform .22s ease;z-index:50;display:flex;
|
|
82
|
+
flex-direction:column}
|
|
83
|
+
#drawer.open{transform:translateY(0)}
|
|
84
|
+
#drawer .dhead{display:flex;justify-content:space-between;align-items:center;padding:13px 18px;
|
|
85
|
+
border-bottom:1px solid var(--bd)}
|
|
86
|
+
#drawer .dtitle{font-weight:600;font-size:14px}
|
|
87
|
+
#drawer .dclose{cursor:pointer;background:none;border:none;color:var(--mut);font-size:24px;
|
|
88
|
+
line-height:1;padding:0 4px}
|
|
89
|
+
#drawer .dclose:hover{color:var(--fg)}
|
|
90
|
+
#drawer .dbody{overflow:auto;padding:6px 18px 20px}
|
|
91
|
+
@media(max-width:820px){
|
|
92
|
+
.layout{flex-direction:column;min-height:0}
|
|
93
|
+
nav{width:auto;height:auto;position:static;border-right:none;
|
|
94
|
+
border-bottom:1px solid var(--bd);display:flex;flex-wrap:wrap;gap:4px;overflow:visible}
|
|
95
|
+
nav button{width:auto;margin:0}
|
|
96
|
+
main{padding:16px}
|
|
97
|
+
.stat{min-width:88px;flex:1 1 88px}
|
|
98
|
+
.bigstat{gap:8px}
|
|
99
|
+
}
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
SCRIPT = """
|
|
103
|
+
const sections=document.querySelectorAll('.section');
|
|
104
|
+
const navs=document.querySelectorAll('nav button');
|
|
105
|
+
function show(v){
|
|
106
|
+
sections.forEach(s=>s.classList.toggle('active',s.id==='sec-'+v));
|
|
107
|
+
navs.forEach(b=>b.classList.toggle('active',b.dataset.v===v));
|
|
108
|
+
}
|
|
109
|
+
navs.forEach(b=>b.addEventListener('click',()=>show(b.dataset.v)));
|
|
110
|
+
document.querySelectorAll('.search').forEach(inp=>{
|
|
111
|
+
inp.addEventListener('input',()=>{
|
|
112
|
+
const q=inp.value.toLowerCase().trim();
|
|
113
|
+
inp.closest('.section').querySelectorAll('.card[data-s]').forEach(c=>{
|
|
114
|
+
c.style.display=(!q||c.dataset.s.includes(q))?'':'none';
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
const backdrop=document.getElementById('backdrop');
|
|
119
|
+
const drawer=document.getElementById('drawer');
|
|
120
|
+
const dtitle=drawer.querySelector('.dtitle');
|
|
121
|
+
const dbody=drawer.querySelector('.dbody');
|
|
122
|
+
function closeDrawer(){
|
|
123
|
+
drawer.classList.remove('open');backdrop.classList.remove('open');dbody.replaceChildren();
|
|
124
|
+
}
|
|
125
|
+
document.querySelectorAll('.more').forEach(btn=>{
|
|
126
|
+
btn.addEventListener('click',()=>{
|
|
127
|
+
const src=btn.parentElement.querySelector('.popup-src');
|
|
128
|
+
dtitle.textContent=btn.dataset.title||'Details';
|
|
129
|
+
dbody.replaceChildren();
|
|
130
|
+
if(src){for(const node of src.children)dbody.appendChild(node.cloneNode(true));}
|
|
131
|
+
drawer.classList.add('open');backdrop.classList.add('open');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
backdrop.addEventListener('click',closeDrawer);
|
|
135
|
+
drawer.querySelector('.dclose').addEventListener('click',closeDrawer);
|
|
136
|
+
document.addEventListener('keydown',ev=>{if(ev.key==='Escape')closeDrawer();});
|
|
137
|
+
show('overview');
|
|
138
|
+
"""
|
capdisc/report/cards.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Catalog-entry cards, dispatched on the discriminated union."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import assert_never
|
|
7
|
+
|
|
8
|
+
from ..catalog import (
|
|
9
|
+
CatalogEntry,
|
|
10
|
+
CatalogMcpServer,
|
|
11
|
+
CatalogPlugin,
|
|
12
|
+
CatalogSkill,
|
|
13
|
+
CatalogTool,
|
|
14
|
+
McpTool,
|
|
15
|
+
)
|
|
16
|
+
from ..html import e, pill
|
|
17
|
+
from .components import PluginComponents, components_block
|
|
18
|
+
from .html import (
|
|
19
|
+
CLASS_MCP,
|
|
20
|
+
CLASS_SKILL,
|
|
21
|
+
CLASS_TOOL,
|
|
22
|
+
card_block,
|
|
23
|
+
desc_block,
|
|
24
|
+
id_span,
|
|
25
|
+
path_block,
|
|
26
|
+
ref_span,
|
|
27
|
+
row_block,
|
|
28
|
+
search_key,
|
|
29
|
+
tag_chips,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# A tool list longer than this moves out of the card into a bottom drawer, so a server with a
|
|
33
|
+
# large surface (playwright, dq) doesn't blow the card open.
|
|
34
|
+
_TOOLS_INLINE_MAX = 6
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _skill_card(skill: CatalogSkill, path: Path | None) -> str:
|
|
38
|
+
head = pill("skill", CLASS_SKILL) + ref_span(skill.ref) + id_span(skill.id)
|
|
39
|
+
inner = row_block(head) + desc_block(skill.description) + tag_chips(list(skill.tags))
|
|
40
|
+
if path is not None:
|
|
41
|
+
inner += path_block(path)
|
|
42
|
+
return card_block(search_key(skill.ref, skill.search_text), inner)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tool_card(tool: CatalogTool) -> str:
|
|
46
|
+
facets = [pill("read-only" if tool.read_only else "writes")]
|
|
47
|
+
if tool.needs_network:
|
|
48
|
+
facets.append(pill("network"))
|
|
49
|
+
head = pill("tool", CLASS_TOOL) + ref_span(tool.ref) + "".join(facets) + id_span(tool.id)
|
|
50
|
+
inner = row_block(head) + desc_block(tool.description) + tag_chips(list(tool.tags))
|
|
51
|
+
return card_block(search_key(tool.ref, tool.search_text), inner)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _tools_table(tools: list[McpTool]) -> str:
|
|
55
|
+
rows = "".join(
|
|
56
|
+
f"<tr><td class='toolname'>{e(t.name)}</td>"
|
|
57
|
+
f"<td class='mono params'>{e(', '.join(t.params))}</td>"
|
|
58
|
+
f"<td>{e(t.description)}</td></tr>"
|
|
59
|
+
for t in tools
|
|
60
|
+
)
|
|
61
|
+
return (
|
|
62
|
+
"<div class='tblwrap'><table class='tbl'><thead><tr>"
|
|
63
|
+
"<th>tool</th><th>params</th><th>description</th>"
|
|
64
|
+
f"</tr></thead><tbody>{rows}</tbody></table></div>"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _mcp_card(server: CatalogMcpServer) -> str:
|
|
69
|
+
head = pill("mcp", CLASS_MCP) + ref_span(server.ref) + id_span(f"{len(server.tools)} tools")
|
|
70
|
+
inner = row_block(head) + desc_block(server.description) + tag_chips(list(server.tags))
|
|
71
|
+
if server.tools:
|
|
72
|
+
table = _tools_table(server.tools)
|
|
73
|
+
if len(server.tools) > _TOOLS_INLINE_MAX:
|
|
74
|
+
inner += (
|
|
75
|
+
f"<button class='more' data-title='{e(server.ref)} · {len(server.tools)} tools'>"
|
|
76
|
+
f"{len(server.tools)} tools ▸</button>"
|
|
77
|
+
f"<div class='popup-src'>{table}</div>"
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
inner += table
|
|
81
|
+
return card_block(search_key(server.ref, server.search_text), inner)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def plugin_card(plugin: CatalogPlugin, components: PluginComponents | None = None) -> str:
|
|
85
|
+
head = pill("plugin") + ref_span(plugin.ref) + id_span(plugin.id)
|
|
86
|
+
inner = row_block(head) + desc_block(plugin.description) + tag_chips(list(plugin.tags))
|
|
87
|
+
if plugin.skills:
|
|
88
|
+
inner += f'<div class="path"><b>skills:</b> {e(", ".join(plugin.skills))}</div>'
|
|
89
|
+
if plugin.mcp_servers:
|
|
90
|
+
inner += f'<div class="path"><b>mcp:</b> {e(", ".join(plugin.mcp_servers))}</div>'
|
|
91
|
+
if components is not None:
|
|
92
|
+
inner += components_block(components)
|
|
93
|
+
return card_block(search_key(plugin.ref, plugin.search_text), inner)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def entry_card(entry: CatalogEntry, location: Path | None) -> str:
|
|
97
|
+
"""Render one catalog entry as a card, dispatched by its variant. `location` is a skill's
|
|
98
|
+
SKILL.md path and is ignored for the other variants."""
|
|
99
|
+
match entry:
|
|
100
|
+
case CatalogSkill():
|
|
101
|
+
return _skill_card(entry, location)
|
|
102
|
+
case CatalogTool():
|
|
103
|
+
return _tool_card(entry)
|
|
104
|
+
case CatalogMcpServer():
|
|
105
|
+
return _mcp_card(entry)
|
|
106
|
+
case CatalogPlugin():
|
|
107
|
+
return plugin_card(entry)
|
|
108
|
+
case _ as unreachable:
|
|
109
|
+
assert_never(unreachable)
|
capdisc/report/cli.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""`capdisc` / `python -m capdisc.report` entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ..settings import get_settings
|
|
9
|
+
from .harvest import REPORT_HTML_NAME, REPORT_JSON_NAME, build_report, write_report
|
|
10
|
+
|
|
11
|
+
_DESCRIPTION = (
|
|
12
|
+
"Scan this machine's Claude Code capabilities — skills, built-in tools, plugins, and "
|
|
13
|
+
"connected MCP servers — and write a JSON snapshot and an HTML report to the default paths."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
"""Build the report against this machine and write both files to the default paths."""
|
|
19
|
+
parser = argparse.ArgumentParser(prog="capdisc", description=_DESCRIPTION)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--oauth",
|
|
22
|
+
action="store_true",
|
|
23
|
+
help="allow the interactive OAuth flow (browser) for HTTP MCP servers with a "
|
|
24
|
+
"pre-registered client in settings; forces a fresh MCP harvest",
|
|
25
|
+
)
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
write_report(build_report(oauth=args.oauth))
|
|
28
|
+
report_dir = get_settings().report_dir
|
|
29
|
+
sys.stdout.write(
|
|
30
|
+
f"wrote {report_dir / REPORT_JSON_NAME}\nwrote {report_dir / REPORT_HTML_NAME}\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
main()
|