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,154 @@
|
|
|
1
|
+
"""Section bodies: each tab's HTML, assembled from cards over one slice of the report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..catalog import CatalogMcpServer, CatalogTool
|
|
8
|
+
from ..html import e, pill, preview, redact_home
|
|
9
|
+
from ..scope import ArtifactKind, CapturedArtifact, ScanRoot, ScopeInventory
|
|
10
|
+
from .cards import entry_card, plugin_card
|
|
11
|
+
from .components import plugin_components
|
|
12
|
+
from .html import (
|
|
13
|
+
card_block,
|
|
14
|
+
empty_block,
|
|
15
|
+
group_label,
|
|
16
|
+
id_span,
|
|
17
|
+
kind_class,
|
|
18
|
+
kv_line,
|
|
19
|
+
path_block,
|
|
20
|
+
ref_span,
|
|
21
|
+
row_block,
|
|
22
|
+
search_key,
|
|
23
|
+
stat_block,
|
|
24
|
+
)
|
|
25
|
+
from .models import EnvironmentReport, IndexedPlugin, IndexedSkill
|
|
26
|
+
|
|
27
|
+
_PREVIEW_LEN = 600
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_roots_section(scan_roots: list[ScanRoot], plugin_dirs: list[Path]) -> str:
|
|
31
|
+
cards: list[str] = []
|
|
32
|
+
for root in scan_roots:
|
|
33
|
+
exists = root.base.exists()
|
|
34
|
+
head = (
|
|
35
|
+
pill(root.scope.value)
|
|
36
|
+
+ f'<span class="ref mono" style="font-size:12.5px">{e(redact_home(root.base))}</span>'
|
|
37
|
+
+ (pill("exists", "eff-b") if exists else pill("missing"))
|
|
38
|
+
)
|
|
39
|
+
if root.kinds is not None:
|
|
40
|
+
head += id_span("kinds: " + ", ".join(sorted(k.value for k in root.kinds)))
|
|
41
|
+
cards.append(
|
|
42
|
+
card_block(
|
|
43
|
+
search_key(root.scope.value, redact_home(root.base)),
|
|
44
|
+
row_block(head),
|
|
45
|
+
"eff" if exists else "shadow",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
if plugin_dirs:
|
|
49
|
+
cards.append(group_label(f"plugin install dirs ({len(plugin_dirs)})"))
|
|
50
|
+
cards.extend(f'<div class="card">{path_block(p)}</div>' for p in plugin_dirs)
|
|
51
|
+
return "".join(cards)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _capture_card(capture: CapturedArtifact, is_effective: bool) -> str:
|
|
55
|
+
head = (
|
|
56
|
+
pill(capture.kind.value, kind_class(capture.kind))
|
|
57
|
+
+ ref_span(capture.name)
|
|
58
|
+
+ pill(f"{capture.scope.value}/{capture.shareable.value}")
|
|
59
|
+
+ (
|
|
60
|
+
pill("effective", "eff-b")
|
|
61
|
+
if is_effective
|
|
62
|
+
else id_span(f"rank {capture.precedence} (shadowed)")
|
|
63
|
+
)
|
|
64
|
+
+ pill(capture.resolution.value)
|
|
65
|
+
)
|
|
66
|
+
inner = row_block(head) + path_block(capture.path) + preview(capture.contents[:_PREVIEW_LEN])
|
|
67
|
+
search = search_key(
|
|
68
|
+
capture.name, capture.kind.value, capture.scope.value, redact_home(capture.path)
|
|
69
|
+
)
|
|
70
|
+
return card_block(search, inner, "eff" if is_effective else "shadow")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_inventory_section(inventory: ScopeInventory) -> str:
|
|
74
|
+
effective = set(inventory.effective)
|
|
75
|
+
captures = sorted(inventory.artifacts, key=lambda c: (c.kind.value, c.name, c.precedence))
|
|
76
|
+
by_kind: dict[ArtifactKind, list[str]] = {}
|
|
77
|
+
for capture in captures:
|
|
78
|
+
by_kind.setdefault(capture.kind, []).append(_capture_card(capture, capture in effective))
|
|
79
|
+
out: list[str] = []
|
|
80
|
+
for kind in ArtifactKind:
|
|
81
|
+
cards = by_kind.get(kind, [])
|
|
82
|
+
if cards:
|
|
83
|
+
out.append(group_label(f"{kind.value} ({len(cards)})"))
|
|
84
|
+
out.extend(cards)
|
|
85
|
+
return "".join(out) or empty_block("nothing captured on disk")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def render_skills_section(skills: list[IndexedSkill]) -> str:
|
|
89
|
+
cards = [entry_card(s.card, s.path) for s in sorted(skills, key=lambda s: s.card.ref)]
|
|
90
|
+
return "".join(cards) or empty_block("no skills found")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render_tools_section(tools: list[CatalogTool]) -> str:
|
|
94
|
+
return "".join(entry_card(tool, None) for tool in tools) or empty_block("no builtin tools")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_plugins_section(
|
|
98
|
+
plugins: list[IndexedPlugin],
|
|
99
|
+
inventory: ScopeInventory,
|
|
100
|
+
mcp_servers: list[CatalogMcpServer],
|
|
101
|
+
) -> str:
|
|
102
|
+
cards = [
|
|
103
|
+
plugin_card(p.card, plugin_components(p, inventory, mcp_servers))
|
|
104
|
+
for p in sorted(plugins, key=lambda p: p.card.ref)
|
|
105
|
+
]
|
|
106
|
+
return "".join(cards) or empty_block("no plugins found")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_mcp_section(servers: list[CatalogMcpServer]) -> str:
|
|
110
|
+
cards = [entry_card(s, None) for s in sorted(servers, key=lambda s: s.ref)]
|
|
111
|
+
return "".join(cards) or empty_block("no MCP servers reachable")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_overview_section(report: EnvironmentReport) -> str:
|
|
115
|
+
top = (
|
|
116
|
+
'<div class="bigstat">'
|
|
117
|
+
+ stat_block(report.skill_count, "skills")
|
|
118
|
+
+ stat_block(report.tool_count, "builtin tools")
|
|
119
|
+
+ stat_block(report.plugin_count, "plugins")
|
|
120
|
+
+ stat_block(report.mcp_server_count, "mcp servers")
|
|
121
|
+
+ stat_block(report.capture_count, "captures on disk")
|
|
122
|
+
+ stat_block(report.hook_config_count, "hook configs")
|
|
123
|
+
+ stat_block(report.scan_root_count, "scan roots")
|
|
124
|
+
+ "</div>"
|
|
125
|
+
)
|
|
126
|
+
env_block = (
|
|
127
|
+
group_label("environment")
|
|
128
|
+
+ '<div class="card">'
|
|
129
|
+
+ kv_line("cwd", report.cwd)
|
|
130
|
+
+ kv_line("home", report.home)
|
|
131
|
+
+ kv_line("plugins root", report.plugins_root)
|
|
132
|
+
+ kv_line("managed dir", report.managed_dir or "(none on this OS)")
|
|
133
|
+
+ kv_line("mcp source", report.mcp_source)
|
|
134
|
+
+ "</div>"
|
|
135
|
+
)
|
|
136
|
+
by_kind: dict[ArtifactKind, int] = {}
|
|
137
|
+
for capture in report.inventory.artifacts:
|
|
138
|
+
by_kind[capture.kind] = by_kind.get(capture.kind, 0) + 1
|
|
139
|
+
kinds = (
|
|
140
|
+
group_label("captures by kind")
|
|
141
|
+
+ '<div class="bigstat">'
|
|
142
|
+
+ "".join(stat_block(n, kind.value) for kind, n in by_kind.items())
|
|
143
|
+
+ "</div>"
|
|
144
|
+
)
|
|
145
|
+
events = sorted({ev.value for cfg in report.inventory.hook_configs for ev in cfg.root})
|
|
146
|
+
hooks = ""
|
|
147
|
+
if events:
|
|
148
|
+
hooks = (
|
|
149
|
+
group_label("hook events in effect")
|
|
150
|
+
+ '<div class="eventbar">'
|
|
151
|
+
+ "".join(f'<span class="event">{e(ev)}</span>' for ev in events)
|
|
152
|
+
+ "</div>"
|
|
153
|
+
)
|
|
154
|
+
return top + env_block + kinds + hooks
|
capdisc/report/types.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Domain-primitive aliases for the report package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
ComponentCount = Annotated[
|
|
10
|
+
int,
|
|
11
|
+
Field(
|
|
12
|
+
ge=0,
|
|
13
|
+
title="Component count",
|
|
14
|
+
description="Number of components (skills, agents, hooks, or MCP servers) in a group.",
|
|
15
|
+
),
|
|
16
|
+
]
|
|
17
|
+
SectionId = Annotated[
|
|
18
|
+
str,
|
|
19
|
+
Field(
|
|
20
|
+
pattern=r"^[a-z]+$",
|
|
21
|
+
title="Section id",
|
|
22
|
+
description="Nav/anchor id of one report tab.",
|
|
23
|
+
examples=["overview", "skills"],
|
|
24
|
+
),
|
|
25
|
+
]
|
|
26
|
+
SectionLabel = Annotated[
|
|
27
|
+
str,
|
|
28
|
+
Field(
|
|
29
|
+
min_length=1,
|
|
30
|
+
max_length=40,
|
|
31
|
+
title="Section label",
|
|
32
|
+
description="Human-readable nav label of one report tab.",
|
|
33
|
+
examples=["Scan roots"],
|
|
34
|
+
),
|
|
35
|
+
]
|
|
36
|
+
SectionCount = Annotated[
|
|
37
|
+
int,
|
|
38
|
+
Field(
|
|
39
|
+
ge=0,
|
|
40
|
+
title="Section count",
|
|
41
|
+
description="Item count shown next to a report tab's nav label.",
|
|
42
|
+
),
|
|
43
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .inventory import (
|
|
4
|
+
CapturedArtifact,
|
|
5
|
+
ScanRoot,
|
|
6
|
+
ScopeInventory,
|
|
7
|
+
ScopeRoots,
|
|
8
|
+
artifact_kinds,
|
|
9
|
+
default_managed_dir,
|
|
10
|
+
parse_frontmatter_hooks,
|
|
11
|
+
parse_hooks,
|
|
12
|
+
render_inventory,
|
|
13
|
+
render_inventory_html,
|
|
14
|
+
)
|
|
15
|
+
from .locations import (
|
|
16
|
+
Artifact,
|
|
17
|
+
CaptureHit,
|
|
18
|
+
Command,
|
|
19
|
+
Hook,
|
|
20
|
+
HostFrontmatter,
|
|
21
|
+
InMemoryFlag,
|
|
22
|
+
Location,
|
|
23
|
+
SettingsEntry,
|
|
24
|
+
Skill,
|
|
25
|
+
StandaloneFile,
|
|
26
|
+
Storage,
|
|
27
|
+
Subagent,
|
|
28
|
+
)
|
|
29
|
+
from .types import (
|
|
30
|
+
ArtifactContents,
|
|
31
|
+
ArtifactKind,
|
|
32
|
+
ArtifactName,
|
|
33
|
+
ArtifactPath,
|
|
34
|
+
ArtifactSubdir,
|
|
35
|
+
CliFlag,
|
|
36
|
+
GlobPattern,
|
|
37
|
+
JsonKey,
|
|
38
|
+
ResolutionMode,
|
|
39
|
+
ResolutionRank,
|
|
40
|
+
ScopeKind,
|
|
41
|
+
ScopeRoot,
|
|
42
|
+
SettingsFile,
|
|
43
|
+
Shareability,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Artifact",
|
|
48
|
+
"ArtifactContents",
|
|
49
|
+
"ArtifactKind",
|
|
50
|
+
"ArtifactName",
|
|
51
|
+
"ArtifactPath",
|
|
52
|
+
"ArtifactSubdir",
|
|
53
|
+
"CaptureHit",
|
|
54
|
+
"CapturedArtifact",
|
|
55
|
+
"CliFlag",
|
|
56
|
+
"Command",
|
|
57
|
+
"GlobPattern",
|
|
58
|
+
"Hook",
|
|
59
|
+
"HostFrontmatter",
|
|
60
|
+
"InMemoryFlag",
|
|
61
|
+
"JsonKey",
|
|
62
|
+
"Location",
|
|
63
|
+
"ResolutionMode",
|
|
64
|
+
"ResolutionRank",
|
|
65
|
+
"ScanRoot",
|
|
66
|
+
"ScopeInventory",
|
|
67
|
+
"ScopeKind",
|
|
68
|
+
"ScopeRoot",
|
|
69
|
+
"ScopeRoots",
|
|
70
|
+
"SettingsEntry",
|
|
71
|
+
"SettingsFile",
|
|
72
|
+
"Shareability",
|
|
73
|
+
"Skill",
|
|
74
|
+
"StandaloneFile",
|
|
75
|
+
"Storage",
|
|
76
|
+
"Subagent",
|
|
77
|
+
"artifact_kinds",
|
|
78
|
+
"default_managed_dir",
|
|
79
|
+
"parse_frontmatter_hooks",
|
|
80
|
+
"parse_hooks",
|
|
81
|
+
"render_inventory",
|
|
82
|
+
"render_inventory_html",
|
|
83
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Scan every artifact location under a set of scope roots into a `ScopeInventory` snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .capture import CapturedArtifact, ScopeInventory, parse_frontmatter_hooks, parse_hooks
|
|
6
|
+
from .render import render_inventory, render_inventory_html
|
|
7
|
+
from .roots import ScanRoot, ScopeRoots, artifact_kinds, default_managed_dir
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CapturedArtifact",
|
|
11
|
+
"ScanRoot",
|
|
12
|
+
"ScopeInventory",
|
|
13
|
+
"ScopeRoots",
|
|
14
|
+
"artifact_kinds",
|
|
15
|
+
"default_managed_dir",
|
|
16
|
+
"parse_frontmatter_hooks",
|
|
17
|
+
"parse_hooks",
|
|
18
|
+
"render_inventory",
|
|
19
|
+
"render_inventory_html",
|
|
20
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Inline CSS and JS for the inventory's self-contained HTML render — no external resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# How many characters of a capture's contents the HTML preview shows.
|
|
6
|
+
PREVIEW_CHARS = 600
|
|
7
|
+
|
|
8
|
+
INVENTORY_STYLE = """
|
|
9
|
+
:root{--bg:#0d1117;--panel:#161b22;--panel2:#1c2430;--bd:#2d3440;--fg:#e6edf3;--mut:#8b949e;
|
|
10
|
+
--ac:#58a6ff;--gn:#3fb950;--yl:#d29922;--pu:#bc8cff;--cy:#39c5cf;}
|
|
11
|
+
*{box-sizing:border-box}
|
|
12
|
+
body{margin:0;background:var(--bg);color:var(--fg);
|
|
13
|
+
font:14px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,sans-serif}
|
|
14
|
+
header{padding:20px 24px;border-bottom:1px solid var(--bd);background:var(--panel)}
|
|
15
|
+
h1{margin:0 0 4px;font-size:20px}
|
|
16
|
+
.meta{color:var(--mut);font-size:12px}.meta b{color:var(--fg)}
|
|
17
|
+
main{padding:20px 24px}
|
|
18
|
+
.search{width:100%;max-width:480px;padding:9px 12px;background:var(--panel2);
|
|
19
|
+
border:1px solid var(--bd);border-radius:8px;color:var(--fg);font-size:13px;margin-bottom:16px}
|
|
20
|
+
.search:focus{outline:none;border-color:var(--ac)}
|
|
21
|
+
.card{background:var(--panel);border:1px solid var(--bd);border-radius:10px;
|
|
22
|
+
padding:14px 16px;margin-bottom:10px}
|
|
23
|
+
.card.eff{border-left:3px solid var(--gn)}.card.shadow{opacity:.62}
|
|
24
|
+
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
25
|
+
.ref{font-weight:600;font-size:14px}.id{color:var(--mut);font-size:11px}
|
|
26
|
+
.path{color:var(--mut);font-size:11.5px;margin-top:6px;word-break:break-all}
|
|
27
|
+
.pill{font-size:10.5px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;
|
|
28
|
+
padding:2px 8px;border-radius:5px}
|
|
29
|
+
.k-skill{background:#1f2d3d;color:var(--ac)}.k-agent{background:#2d1f3d;color:var(--pu)}
|
|
30
|
+
.k-command{background:#1f3d2a;color:var(--gn)}.k-hook{background:#3d2f1f;color:var(--yl)}
|
|
31
|
+
.sc{background:var(--panel2);border:1px solid var(--bd);color:var(--mut)}
|
|
32
|
+
.eff-b{background:#16341f;color:var(--gn)}
|
|
33
|
+
.preview{margin-top:8px;background:var(--bg);border:1px solid var(--bd);border-radius:6px;
|
|
34
|
+
padding:8px 10px;color:var(--mut);font-size:11.5px;white-space:pre-wrap;
|
|
35
|
+
max-height:160px;overflow:auto;display:none}
|
|
36
|
+
.card.open .preview{display:block}
|
|
37
|
+
.toggle{cursor:pointer;color:var(--ac);font-size:11px;background:none;border:none;padding:0;margin-top:6px}
|
|
38
|
+
.grp{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin:18px 0 8px}
|
|
39
|
+
.empty{color:var(--mut);padding:24px;text-align:center}
|
|
40
|
+
.eventbar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
|
|
41
|
+
.event{background:#3d2f1f;color:var(--yl);border-radius:5px;padding:2px 9px;font-size:11.5px}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
INVENTORY_SCRIPT = """
|
|
45
|
+
const search=document.querySelector('.search');
|
|
46
|
+
if(search){
|
|
47
|
+
search.addEventListener('input',()=>{
|
|
48
|
+
const q=search.value.toLowerCase().trim();
|
|
49
|
+
document.querySelectorAll('.card[data-s]').forEach(c=>{
|
|
50
|
+
c.style.display=(!q||c.dataset.s.includes(q))?'':'none';
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
"""
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The capture engine: scans artifact locations into `CapturedArtifact` records, parses each
|
|
2
|
+
capture's hook config, and resolves the effective (collision-winning) set."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pydantic import JsonValue, ValidationError
|
|
7
|
+
|
|
8
|
+
from ...base import FrozenModel, InputModel
|
|
9
|
+
from ...frontmatter import read_frontmatter
|
|
10
|
+
from ...hooks import HookConfig
|
|
11
|
+
from ..locations import Artifact, Location
|
|
12
|
+
from ..types import (
|
|
13
|
+
ArtifactContents,
|
|
14
|
+
ArtifactKind,
|
|
15
|
+
ArtifactName,
|
|
16
|
+
ArtifactPath,
|
|
17
|
+
ResolutionMode,
|
|
18
|
+
ResolutionRank,
|
|
19
|
+
ScopeKind,
|
|
20
|
+
Shareability,
|
|
21
|
+
)
|
|
22
|
+
from .roots import ScanRoot, ScopeRoots, artifact_kinds
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CapturedArtifact(FrozenModel):
|
|
26
|
+
"""One definition found on disk: its placement, exact path, contents, and resolution rank."""
|
|
27
|
+
|
|
28
|
+
kind: ArtifactKind
|
|
29
|
+
scope: ScopeKind
|
|
30
|
+
shareable: Shareability
|
|
31
|
+
resolution: ResolutionMode
|
|
32
|
+
name: ArtifactName
|
|
33
|
+
path: ArtifactPath
|
|
34
|
+
contents: ArtifactContents
|
|
35
|
+
precedence: ResolutionRank
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _WrappedHooks(InputModel):
|
|
39
|
+
"""A plugin hooks.json body: the event map under a `hooks` key (description etc. ignored)."""
|
|
40
|
+
|
|
41
|
+
hooks: JsonValue = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_hooks(captured: CapturedArtifact) -> HookConfig | None:
|
|
45
|
+
"""Parse a captured hook artifact into a typed `HookConfig`.
|
|
46
|
+
|
|
47
|
+
Ingest is fail-soft: a config using an event, handler type, or structure newer than we model
|
|
48
|
+
skips rather than crashing the scan — unknown *fields* are already tolerated by
|
|
49
|
+
`FrozenWireModel`, so this only trips on shape we cannot represent at all.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
captured: A captured hook artifact; its contents are a settings.json `hooks` slice
|
|
53
|
+
(the bare event map) or a plugin hooks.json (that same map under a `hooks` key).
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The typed config, or None if it cannot be parsed.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
return HookConfig.model_validate_json(captured.contents)
|
|
60
|
+
except ValidationError:
|
|
61
|
+
pass
|
|
62
|
+
try:
|
|
63
|
+
wrapped = _WrappedHooks.model_validate_json(captured.contents).hooks
|
|
64
|
+
return None if wrapped is None else HookConfig.model_validate(wrapped)
|
|
65
|
+
except ValidationError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _frontmatter_hooks_value(text: str) -> JsonValue | None:
|
|
70
|
+
"""The `hooks` value from a markdown file's frontmatter, or None if absent or not a mapping."""
|
|
71
|
+
data = read_frontmatter(text)
|
|
72
|
+
return None if data is None else data.get("hooks")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_frontmatter_hooks(captured: CapturedArtifact) -> HookConfig | None:
|
|
76
|
+
"""Parse the hooks a host agent or skill declares in its frontmatter.
|
|
77
|
+
|
|
78
|
+
These are the component-scope hooks, active while their host is. Fail-soft like `parse_hooks`.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
captured: A captured agent or skill artifact whose frontmatter may declare `hooks`.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The typed config, or None if it declares none or they cannot be parsed.
|
|
85
|
+
"""
|
|
86
|
+
hooks = _frontmatter_hooks_value(captured.contents)
|
|
87
|
+
if hooks is None:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return HookConfig.model_validate(hooks)
|
|
91
|
+
except ValidationError:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _ranked_roots(artifact: Artifact, roots: list[ScanRoot]) -> list[ScanRoot]:
|
|
96
|
+
"""The scan roots an artifact loads from, ordered by its own precedence.
|
|
97
|
+
|
|
98
|
+
Roots are keyed by the artifact's scope order, then by scan position so a nearer project dir
|
|
99
|
+
outranks a farther one. Position in the result is the collision rank (0 wins).
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
artifact: The artifact descriptor whose scope order and kind-filter apply.
|
|
103
|
+
roots: All scan roots to filter and rank.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The applicable roots, highest precedence first; roots whose scope the artifact has no
|
|
107
|
+
location for (e.g. a managed dir for a command), or whose `kinds` exclude it, are dropped.
|
|
108
|
+
"""
|
|
109
|
+
order = {location.scope: index for index, location in enumerate(artifact.locations)}
|
|
110
|
+
ranked = [
|
|
111
|
+
(position, root)
|
|
112
|
+
for position, root in enumerate(roots)
|
|
113
|
+
if root.scope in order and (root.kinds is None or artifact.kind in root.kinds)
|
|
114
|
+
]
|
|
115
|
+
ranked.sort(key=lambda pair: (order[pair[1].scope], pair[0]))
|
|
116
|
+
return [root for _, root in ranked]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ScopeInventory(FrozenModel):
|
|
120
|
+
"""A single snapshot of every definition across every scanned location — the concrete
|
|
121
|
+
grounding of the abstract location taxonomy. One instance holds the lot."""
|
|
122
|
+
|
|
123
|
+
artifacts: list[CapturedArtifact] = []
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _scan_artifact(artifact: Artifact, roots: ScopeRoots) -> list[CapturedArtifact]:
|
|
127
|
+
"""Capture every on-disk definition for one artifact descriptor.
|
|
128
|
+
|
|
129
|
+
A scope can hold more than one location (a hook lives in both `settings.json` and
|
|
130
|
+
`settings.local.json`), so every location for the matched scope is scanned, not just one.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
artifact: The artifact descriptor to scan for.
|
|
134
|
+
roots: The scan roots to look under.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Every capture found, each carrying its location's scope/shareability and the
|
|
138
|
+
artifact's collision rank.
|
|
139
|
+
"""
|
|
140
|
+
by_scope: dict[ScopeKind, list[Location]] = {}
|
|
141
|
+
for location in artifact.locations:
|
|
142
|
+
by_scope.setdefault(location.scope, []).append(location)
|
|
143
|
+
|
|
144
|
+
captured: list[CapturedArtifact] = []
|
|
145
|
+
for precedence, root in enumerate(_ranked_roots(artifact, roots.roots)):
|
|
146
|
+
for location in by_scope[root.scope]:
|
|
147
|
+
captured.extend(
|
|
148
|
+
CapturedArtifact(
|
|
149
|
+
kind=artifact.kind,
|
|
150
|
+
scope=location.scope,
|
|
151
|
+
shareable=location.shareable,
|
|
152
|
+
resolution=artifact.resolution,
|
|
153
|
+
name=hit.name,
|
|
154
|
+
path=hit.path,
|
|
155
|
+
contents=hit.contents,
|
|
156
|
+
precedence=precedence,
|
|
157
|
+
)
|
|
158
|
+
for hit in location.storage.discover(root.base)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return captured
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def hook_configs(self) -> list[HookConfig]:
|
|
165
|
+
"""Every hook config in effect across the snapshot: settings/plugin `hooks` entries plus
|
|
166
|
+
those declared in an agent's or skill's frontmatter. Unparseable captures are skipped.
|
|
167
|
+
|
|
168
|
+
Reads from `effective`, not `artifacts` — an agent or skill shadowed by a
|
|
169
|
+
higher-precedence same-named capture never loads, so its frontmatter hooks never run."""
|
|
170
|
+
configs: list[HookConfig] = []
|
|
171
|
+
for captured in self.effective:
|
|
172
|
+
if captured.kind is ArtifactKind.hook:
|
|
173
|
+
config = parse_hooks(captured)
|
|
174
|
+
elif captured.kind in (ArtifactKind.agent, ArtifactKind.skill):
|
|
175
|
+
config = parse_frontmatter_hooks(captured)
|
|
176
|
+
else:
|
|
177
|
+
config = None
|
|
178
|
+
if config is not None:
|
|
179
|
+
configs.append(config)
|
|
180
|
+
return configs
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def effective(self) -> list[CapturedArtifact]:
|
|
184
|
+
"""What takes effect: for each name-collision (kind, name) the lowest-rank capture, plus
|
|
185
|
+
every additive capture (hooks) unchanged. A plain property — derived from the precedence
|
|
186
|
+
rank on each capture, so it is order-independent rather than relying on scan order."""
|
|
187
|
+
best: dict[tuple[ArtifactKind, ArtifactName], CapturedArtifact] = {}
|
|
188
|
+
additive: list[CapturedArtifact] = []
|
|
189
|
+
for captured in self.artifacts:
|
|
190
|
+
if captured.resolution is ResolutionMode.additive:
|
|
191
|
+
additive.append(captured)
|
|
192
|
+
continue
|
|
193
|
+
identity = (captured.kind, captured.name)
|
|
194
|
+
current = best.get(identity)
|
|
195
|
+
if current is None or captured.precedence < current.precedence:
|
|
196
|
+
best[identity] = captured
|
|
197
|
+
return list(best.values()) + additive
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def scan(cls, roots: ScopeRoots) -> ScopeInventory:
|
|
201
|
+
"""Scan every artifact's locations under `roots` into one snapshot.
|
|
202
|
+
|
|
203
|
+
Walks each artifact's locations under every applicable root, dispatching to each storage
|
|
204
|
+
shape's `discover`. Each capture's rank is its position in that artifact's own precedence
|
|
205
|
+
order, so the right scope wins per kind (skills rank user over project, subagents the
|
|
206
|
+
reverse) and nearer project dirs win on collision.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
roots: The scan roots, as resolved by `ScopeRoots.discover`.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
The inventory snapshot of every captured artifact.
|
|
213
|
+
"""
|
|
214
|
+
captured = [
|
|
215
|
+
capture
|
|
216
|
+
for artifact in artifact_kinds()
|
|
217
|
+
for capture in cls._scan_artifact(artifact, roots)
|
|
218
|
+
]
|
|
219
|
+
return cls(artifacts=captured)
|