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,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
+ ]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ """
@@ -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 &#9656;</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()