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,149 @@
|
|
|
1
|
+
"""Render a `ScopeInventory` snapshot as plain text or a self-contained HTML page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ...html import e, pill, preview, redact_home
|
|
6
|
+
from ..types import ScopeKind
|
|
7
|
+
from .assets import INVENTORY_SCRIPT, INVENTORY_STYLE, PREVIEW_CHARS
|
|
8
|
+
from .capture import CapturedArtifact, ScopeInventory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_inventory(inventory: ScopeInventory) -> str:
|
|
12
|
+
"""Render a snapshot as human-readable text. Pure — returns text, prints nothing.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
inventory: The snapshot to render.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
A multi-line report: every capture grouped by scope (in precedence order), then the
|
|
19
|
+
effective set and the hook events in effect.
|
|
20
|
+
"""
|
|
21
|
+
order = {scope: index for index, scope in enumerate(ScopeKind)}
|
|
22
|
+
captures = sorted(
|
|
23
|
+
inventory.artifacts,
|
|
24
|
+
key=lambda c: (order[c.scope], c.kind.value, c.precedence, c.name),
|
|
25
|
+
)
|
|
26
|
+
lines = [f"ScopeInventory — {len(inventory.artifacts)} artifacts"]
|
|
27
|
+
current_scope: ScopeKind | None = None
|
|
28
|
+
for capture in captures:
|
|
29
|
+
if capture.scope is not current_scope:
|
|
30
|
+
current_scope = capture.scope
|
|
31
|
+
lines.append(f"\n[{capture.scope.value}]")
|
|
32
|
+
lines.append(
|
|
33
|
+
f" {capture.kind.value:<7} {capture.name:<22} {capture.shareable.value:<13}"
|
|
34
|
+
f" rank={capture.precedence} {capture.path}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
lines.append("\neffective (what Claude Code uses):")
|
|
38
|
+
for capture in sorted(inventory.effective, key=lambda c: (c.kind.value, c.name)):
|
|
39
|
+
lines.append(
|
|
40
|
+
f" {capture.kind.value:<7} {capture.name:<22} <- {capture.scope.value}/"
|
|
41
|
+
f"{capture.shareable.value}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
events = sorted({event.value for config in inventory.hook_configs for event in config.root})
|
|
45
|
+
lines.append(
|
|
46
|
+
f"\nhook configs: {len(inventory.hook_configs)} events: {', '.join(events) or '(none)'}"
|
|
47
|
+
)
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _artifact_card(capture: CapturedArtifact, is_effective: bool) -> str:
|
|
52
|
+
"""One capture rendered as a card: header pills, path, and a content preview.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
capture: The capture to render; every dynamic value is escaped.
|
|
56
|
+
is_effective: Whether this capture is in the effective set (wins resolution).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The card markup, tagged with a lowercased `data-s` search string for the filter.
|
|
60
|
+
"""
|
|
61
|
+
marker = (
|
|
62
|
+
pill("effective", "eff-b")
|
|
63
|
+
if is_effective
|
|
64
|
+
else f'<span class="id">rank {e(capture.precedence)} (shadowed)</span>'
|
|
65
|
+
)
|
|
66
|
+
head = (
|
|
67
|
+
pill(capture.kind.value, f"k-{capture.kind.value}")
|
|
68
|
+
+ f'<span class="ref">{e(capture.name)}</span>'
|
|
69
|
+
+ pill(f"{capture.scope.value}/{capture.shareable.value}")
|
|
70
|
+
+ marker
|
|
71
|
+
+ pill(capture.resolution.value)
|
|
72
|
+
)
|
|
73
|
+
path_line = f'<div class="path">{e(redact_home(capture.path))}</div>'
|
|
74
|
+
inner = f'<div class="row">{head}</div>{path_line}' + preview(capture.contents[:PREVIEW_CHARS])
|
|
75
|
+
terms = (capture.name, capture.kind.value, capture.scope.value, redact_home(capture.path))
|
|
76
|
+
search = e(" ".join(terms).lower())
|
|
77
|
+
klass = "eff" if is_effective else "shadow"
|
|
78
|
+
return f'<div class="card {klass}" data-s="{search}">{inner}</div>'
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_inventory_html(inventory: ScopeInventory) -> str:
|
|
82
|
+
"""Render a snapshot as one self-contained HTML document. Pure — returns the markup
|
|
83
|
+
string, prints nothing, does no I/O.
|
|
84
|
+
|
|
85
|
+
All markup is built here in Python and every dynamic value is escaped; the inline
|
|
86
|
+
script only toggles classes, sets `style.display`, and reads the filter input — it
|
|
87
|
+
never assigns markup. Inline `<style>` and `<script>` only, no external assets.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
inventory: The snapshot to render.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A standalone HTML document: every capture grouped by scope (in precedence order)
|
|
94
|
+
as a card, then the effective set and the hook events in effect.
|
|
95
|
+
"""
|
|
96
|
+
effective = set(inventory.effective)
|
|
97
|
+
order = {scope: index for index, scope in enumerate(ScopeKind)}
|
|
98
|
+
captures = sorted(
|
|
99
|
+
inventory.artifacts,
|
|
100
|
+
key=lambda c: (order[c.scope], c.kind.value, c.precedence, c.name),
|
|
101
|
+
)
|
|
102
|
+
cards: list[str] = []
|
|
103
|
+
current_scope: ScopeKind | None = None
|
|
104
|
+
for capture in captures:
|
|
105
|
+
if capture.scope is not current_scope:
|
|
106
|
+
current_scope = capture.scope
|
|
107
|
+
cards.append(f'<div class="grp">{e(capture.scope.value)}</div>')
|
|
108
|
+
cards.append(_artifact_card(capture, capture in effective))
|
|
109
|
+
captures_html = "".join(cards) or '<div class="empty">nothing captured on disk</div>'
|
|
110
|
+
|
|
111
|
+
effective_rows = (
|
|
112
|
+
"".join(
|
|
113
|
+
f'<div class="card eff"><div class="row">'
|
|
114
|
+
f"{pill(c.kind.value, f'k-{c.kind.value}')}"
|
|
115
|
+
f'<span class="ref">{e(c.name)}</span>'
|
|
116
|
+
f"{pill(f'{c.scope.value}/{c.shareable.value}')}</div></div>"
|
|
117
|
+
for c in sorted(inventory.effective, key=lambda c: (c.kind.value, c.name))
|
|
118
|
+
)
|
|
119
|
+
or '<div class="empty">nothing effective</div>'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
events = sorted({event.value for config in inventory.hook_configs for event in config.root})
|
|
123
|
+
event_bar = (
|
|
124
|
+
'<div class="eventbar">'
|
|
125
|
+
+ "".join(f'<span class="event">{e(ev)}</span>' for ev in events)
|
|
126
|
+
+ "</div>"
|
|
127
|
+
if events
|
|
128
|
+
else '<div class="empty">no hook events in effect</div>'
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
133
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
134
|
+
"<title>ScopeInventory</title>"
|
|
135
|
+
f"<style>{INVENTORY_STYLE}</style></head><body>"
|
|
136
|
+
"<header><h1>ScopeInventory</h1>"
|
|
137
|
+
f'<div class="meta"><b>{e(len(inventory.artifacts))}</b> artifacts · '
|
|
138
|
+
f"<b>{e(len(inventory.effective))}</b> effective · "
|
|
139
|
+
f"<b>{e(len(inventory.hook_configs))}</b> hook configs</div></header>"
|
|
140
|
+
"<main>"
|
|
141
|
+
'<input class="search" placeholder="filter captures…">'
|
|
142
|
+
f'<div class="grp">captures by scope</div>{captures_html}'
|
|
143
|
+
f'<div class="grp">effective (what Claude Code uses)</div>{effective_rows}'
|
|
144
|
+
f'<div class="grp">hook events in effect '
|
|
145
|
+
f"({e(len(inventory.hook_configs))} configs)</div>"
|
|
146
|
+
f"{event_bar}"
|
|
147
|
+
"</main>"
|
|
148
|
+
f"<script>{INVENTORY_SCRIPT}</script></body></html>"
|
|
149
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Root/path discovery: the real `.claude`/managed directories to scan for each scope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import get_args
|
|
9
|
+
|
|
10
|
+
from ...base import FrozenModel
|
|
11
|
+
from ..locations import Artifact
|
|
12
|
+
from ..types import ArtifactKind, ScanBasePath, ScopeKind
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def artifact_kinds() -> list[Artifact]:
|
|
16
|
+
"""One canonical instance of every artifact descriptor, derived from the union, not listed."""
|
|
17
|
+
return [variant() for variant in get_args(get_args(Artifact)[0])]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _project_bases(start: Path) -> list[Path]:
|
|
21
|
+
"""The project-scope `.claude` directories, walking up from `start` to the repo root.
|
|
22
|
+
|
|
23
|
+
How Claude Code finds project scope no matter which subdirectory you launched from.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
start: The directory the search walks up from (resolved first).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Each `.claude` from `start` up to and including the repo root, nearest first. With no
|
|
30
|
+
enclosing repo, just the start dir's own `.claude`, or `[]` if it has none.
|
|
31
|
+
"""
|
|
32
|
+
start = start.resolve()
|
|
33
|
+
bases: list[Path] = []
|
|
34
|
+
for current in (start, *start.parents):
|
|
35
|
+
claude_dir = current / ".claude"
|
|
36
|
+
if claude_dir.is_dir():
|
|
37
|
+
bases.append(claude_dir)
|
|
38
|
+
if (current / ".git").exists():
|
|
39
|
+
return bases
|
|
40
|
+
start_claude = start / ".claude"
|
|
41
|
+
return [start_claude] if start_claude.is_dir() else []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Directories never worth descending into when looking for nested skills.
|
|
45
|
+
_IGNORED_DIRS = frozenset({".git", ".venv", "venv", "node_modules", "__pycache__", ".tox"})
|
|
46
|
+
_STANDALONE_KINDS = frozenset({ArtifactKind.agent, ArtifactKind.skill, ArtifactKind.command})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _nested_skill_bases(start: Path) -> list[Path]:
|
|
50
|
+
"""The `.claude` directories below `start`, for skills.
|
|
51
|
+
|
|
52
|
+
Skills (unlike agents) also load from nested `.claude/skills/` in subdirectories.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
start: The directory to search beneath (resolved first).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Every `.claude` strictly below `start`, sorted; the start's own `.claude` is excluded (it
|
|
59
|
+
is already an upward base) and noise dirs (`.git`, `node_modules`, …) are pruned.
|
|
60
|
+
"""
|
|
61
|
+
start = start.resolve()
|
|
62
|
+
own = start / ".claude"
|
|
63
|
+
bases: list[Path] = []
|
|
64
|
+
for root, dirnames, _files in os.walk(start):
|
|
65
|
+
# prune in place so os.walk never descends into a noise tree at all — unlike rglob,
|
|
66
|
+
# which would first walk the whole subtree and only filter its matches afterward
|
|
67
|
+
dirnames[:] = [d for d in dirnames if d not in _IGNORED_DIRS]
|
|
68
|
+
if ".claude" in dirnames:
|
|
69
|
+
candidate = Path(root) / ".claude"
|
|
70
|
+
if candidate != own:
|
|
71
|
+
bases.append(candidate)
|
|
72
|
+
return sorted(bases)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_MANAGED_DIRS: dict[str, Path] = {
|
|
76
|
+
"darwin": Path("/Library/Application Support/ClaudeCode"),
|
|
77
|
+
"linux": Path("/etc/claude-code"),
|
|
78
|
+
"win32": Path(r"C:\Program Files\ClaudeCode"),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def default_managed_dir() -> Path | None:
|
|
83
|
+
"""The OS file-based managed-settings directory for this platform.
|
|
84
|
+
|
|
85
|
+
Only the file delivery mechanism is a path; MDM, registry, and server-delivered managed
|
|
86
|
+
settings are not files and cannot be scanned here.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The platform's managed dir, or None on an unrecognised platform.
|
|
90
|
+
"""
|
|
91
|
+
for prefix, path in _MANAGED_DIRS.items():
|
|
92
|
+
if sys.platform.startswith(prefix):
|
|
93
|
+
return path
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ScanRoot(FrozenModel):
|
|
98
|
+
"""A real base directory to scan for a scope. `kinds` restricts which artifact kinds use it
|
|
99
|
+
(None = all): a nested-skill root serves only skills, a managed `.claude` only standalone
|
|
100
|
+
files, while the managed root itself serves the settings file."""
|
|
101
|
+
|
|
102
|
+
scope: ScopeKind
|
|
103
|
+
base: ScanBasePath
|
|
104
|
+
kinds: frozenset[ArtifactKind] | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ScopeRoots(FrozenModel):
|
|
108
|
+
"""The base directories to scan, in precedence order (highest first). Build with `discover`
|
|
109
|
+
so project scope is resolved by walking up to the repo root, not assumed to be the cwd."""
|
|
110
|
+
|
|
111
|
+
roots: list[ScanRoot] = []
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def discover(
|
|
115
|
+
cls,
|
|
116
|
+
*,
|
|
117
|
+
start: Path,
|
|
118
|
+
home_dir: Path | None = None,
|
|
119
|
+
managed_dir: Path | None = None,
|
|
120
|
+
plugin_dirs: list[Path] | None = None,
|
|
121
|
+
add_dirs: list[Path] | None = None,
|
|
122
|
+
) -> ScopeRoots:
|
|
123
|
+
"""Resolve every scannable root from where Claude Code was started.
|
|
124
|
+
|
|
125
|
+
- project: every `.claude` from `start` up to the repo root (all kinds), plus every
|
|
126
|
+
`.claude` *below* `start` for skills, plus each `--add-dir`'s `.claude`.
|
|
127
|
+
- user / managed: included only when their dir is given — `discover` never auto-resolves
|
|
128
|
+
them. Managed splits: the dir itself holds `managed-settings.json`; its `.claude` holds
|
|
129
|
+
standalone files.
|
|
130
|
+
- plugin: the explicit `plugin_dirs` only.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
start: The launch directory; project scope is resolved by walking up from here.
|
|
134
|
+
home_dir: The user's home; when given, its `.claude` is the user-scope root.
|
|
135
|
+
managed_dir: The OS managed dir; when given, it and its `.claude` are added (use
|
|
136
|
+
`default_managed_dir()` to obtain it).
|
|
137
|
+
plugin_dirs: Installed-plugin roots to scan; enabled-plugin resolution is the
|
|
138
|
+
caller's job, since plugins resolve through `enabledPlugins` + marketplaces.
|
|
139
|
+
add_dirs: Extra `--add-dir` roots; each contributes its `.claude` as project scope.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The scan roots in precedence order, highest first.
|
|
143
|
+
"""
|
|
144
|
+
plugin_dirs = plugin_dirs or []
|
|
145
|
+
add_dirs = add_dirs or []
|
|
146
|
+
roots: list[ScanRoot] = []
|
|
147
|
+
if managed_dir is not None:
|
|
148
|
+
roots.append(ScanRoot(scope=ScopeKind.managed, base=managed_dir))
|
|
149
|
+
roots.append(
|
|
150
|
+
ScanRoot(
|
|
151
|
+
scope=ScopeKind.managed,
|
|
152
|
+
base=managed_dir / ".claude",
|
|
153
|
+
kinds=_STANDALONE_KINDS,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
roots += [ScanRoot(scope=ScopeKind.project, base=base) for base in _project_bases(start)]
|
|
157
|
+
roots += [
|
|
158
|
+
ScanRoot(scope=ScopeKind.project, base=base, kinds=frozenset({ArtifactKind.skill}))
|
|
159
|
+
for base in _nested_skill_bases(start)
|
|
160
|
+
]
|
|
161
|
+
# resolved, like _project_bases/_nested_skill_bases: an unresolved add-dir that's a
|
|
162
|
+
# symlink to (or through) `start` or another add-dir would otherwise scan the same
|
|
163
|
+
# physical directory twice, double-capturing every additive artifact it holds.
|
|
164
|
+
seen_project_bases = {
|
|
165
|
+
root.base.resolve() for root in roots if root.scope == ScopeKind.project
|
|
166
|
+
}
|
|
167
|
+
for add in add_dirs:
|
|
168
|
+
claude_dir = (add / ".claude").resolve()
|
|
169
|
+
if claude_dir.is_dir() and claude_dir not in seen_project_bases:
|
|
170
|
+
seen_project_bases.add(claude_dir)
|
|
171
|
+
roots.append(ScanRoot(scope=ScopeKind.project, base=claude_dir))
|
|
172
|
+
if home_dir is not None:
|
|
173
|
+
roots.append(ScanRoot(scope=ScopeKind.user, base=home_dir / ".claude"))
|
|
174
|
+
roots += [ScanRoot(scope=ScopeKind.plugin, base=plugin) for plugin in plugin_dirs]
|
|
175
|
+
return cls(roots=roots)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, JsonValue, TypeAdapter, ValidationError
|
|
7
|
+
|
|
8
|
+
from ..base import FrozenModel
|
|
9
|
+
from .types import (
|
|
10
|
+
ARTIFACT_CONTENT_MAX_CHARS,
|
|
11
|
+
ArtifactContents,
|
|
12
|
+
ArtifactKind,
|
|
13
|
+
ArtifactName,
|
|
14
|
+
ArtifactPath,
|
|
15
|
+
ArtifactSubdir,
|
|
16
|
+
CliFlag,
|
|
17
|
+
GlobPattern,
|
|
18
|
+
JsonKey,
|
|
19
|
+
ResolutionMode,
|
|
20
|
+
ScopeKind,
|
|
21
|
+
ScopeRoot,
|
|
22
|
+
SettingsFile,
|
|
23
|
+
Shareability,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CaptureHit(FrozenModel):
|
|
28
|
+
"""One concrete definition a storage shape found under a real base path."""
|
|
29
|
+
|
|
30
|
+
path: ArtifactPath
|
|
31
|
+
name: ArtifactName
|
|
32
|
+
contents: ArtifactContents
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _safe_read(path: Path, base: Path) -> str | None:
|
|
36
|
+
"""Contents of `path` if it resolves inside `base`, is readable UTF-8, and fits the cap;
|
|
37
|
+
None to skip it. A dangling symlink, permission-denied, or non-UTF-8 file skips itself —
|
|
38
|
+
one bad file must never abort the scan.
|
|
39
|
+
|
|
40
|
+
Bounded to `ARTIFACT_CONTENT_MAX_CHARS` (the `ArtifactContents` cap) so an enormous file
|
|
41
|
+
cannot OOM the scan before validation; over-limit or symlink-escaping files are skipped,
|
|
42
|
+
not partially read."""
|
|
43
|
+
if not path.resolve().is_relative_to(base.resolve()):
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
47
|
+
text = handle.read(ARTIFACT_CONTENT_MAX_CHARS + 1)
|
|
48
|
+
except (OSError, UnicodeDecodeError):
|
|
49
|
+
return None
|
|
50
|
+
return None if len(text) > ARTIFACT_CONTENT_MAX_CHARS else text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_JSON_OBJECT: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue])
|
|
54
|
+
_JSON_VALUE: TypeAdapter[JsonValue] = TypeAdapter(JsonValue)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _extract_entry(text: str, key: JsonKey | None) -> str | None:
|
|
58
|
+
"""The JSON slice a settings-entry artifact captures: the whole object when `key` is None
|
|
59
|
+
(a bare hooks.json), or that key's value (a `hooks` block inside settings.json). None when
|
|
60
|
+
the text is not a JSON object or the key is absent."""
|
|
61
|
+
try:
|
|
62
|
+
data = _JSON_OBJECT.validate_json(text)
|
|
63
|
+
except ValidationError:
|
|
64
|
+
return None
|
|
65
|
+
if key is None:
|
|
66
|
+
return text
|
|
67
|
+
if key not in data:
|
|
68
|
+
return None
|
|
69
|
+
return _JSON_VALUE.dump_json(data[key]).decode()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- Axis 1: storage shape ------------------------------------------------------------
|
|
73
|
+
# How a definition is physically stored — independent of which scope it sits in. Each shape
|
|
74
|
+
# renders an abstract place (for the taxonomy) and discovers concrete hits (for a scan).
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class StandaloneFile(FrozenModel):
|
|
78
|
+
"""A file of its own under <root>/<subdir>/ — agents, skills, commands."""
|
|
79
|
+
|
|
80
|
+
storage_kind: Literal["standalone_file"] = "standalone_file"
|
|
81
|
+
subdir: ArtifactSubdir
|
|
82
|
+
glob: GlobPattern
|
|
83
|
+
name_from: Literal["stem", "parent"] = "stem"
|
|
84
|
+
|
|
85
|
+
def discover(self, base: Path) -> list[CaptureHit]:
|
|
86
|
+
"""Capture every file matching this shape's glob under `base/<subdir>`.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
base: The scanned scope root; each hit's path must resolve inside it.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
One hit per readable, in-bounds file, named by stem or parent dir per `name_from`;
|
|
93
|
+
files that escape `base` or exceed the size cap are skipped.
|
|
94
|
+
"""
|
|
95
|
+
directory = base / self.subdir
|
|
96
|
+
hits: list[CaptureHit] = []
|
|
97
|
+
for path in sorted(directory.glob(self.glob)):
|
|
98
|
+
contents = _safe_read(path, base)
|
|
99
|
+
if contents is None:
|
|
100
|
+
continue
|
|
101
|
+
name = path.parent.name if self.name_from == "parent" else path.stem
|
|
102
|
+
hits.append(CaptureHit(path=path, name=name, contents=contents))
|
|
103
|
+
return hits
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SettingsEntry(FrozenModel):
|
|
107
|
+
"""A key inside a JSON settings file — hooks, MCP servers, permissions. A None `key` means
|
|
108
|
+
the file's root object is the entry (a plugin's bare hooks.json), not a nested key."""
|
|
109
|
+
|
|
110
|
+
storage_kind: Literal["settings_entry"] = "settings_entry"
|
|
111
|
+
file: SettingsFile
|
|
112
|
+
key: JsonKey | None = None
|
|
113
|
+
|
|
114
|
+
def discover(self, base: Path) -> list[CaptureHit]:
|
|
115
|
+
"""Capture this shape's settings entry from `base/<file>`.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
base: The scanned scope root; the file's path must resolve inside it.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
A single hit holding the extracted entry (the whole object when `key` is None, else
|
|
122
|
+
that key's value), or `[]` if the file is missing, out of bounds, not JSON, or the
|
|
123
|
+
key is absent.
|
|
124
|
+
"""
|
|
125
|
+
path = base / self.file
|
|
126
|
+
if not path.is_file():
|
|
127
|
+
return []
|
|
128
|
+
text = _safe_read(path, base)
|
|
129
|
+
if text is None:
|
|
130
|
+
return []
|
|
131
|
+
entry = _extract_entry(text, self.key)
|
|
132
|
+
if entry is None:
|
|
133
|
+
return []
|
|
134
|
+
return [CaptureHit(path=path, name=self.key or self.file, contents=entry)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class HostFrontmatter(FrozenModel):
|
|
138
|
+
"""Embedded in another component's frontmatter — a hook defined inside a skill or agent."""
|
|
139
|
+
|
|
140
|
+
storage_kind: Literal["host_frontmatter"] = "host_frontmatter"
|
|
141
|
+
key: JsonKey
|
|
142
|
+
|
|
143
|
+
def discover(self, base: Path) -> list[CaptureHit]: # noqa: ARG002 — uniform Storage interface
|
|
144
|
+
"""Always empty — a frontmatter-embedded hook is captured with its host, not standalone."""
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class InMemoryFlag(FrozenModel):
|
|
149
|
+
"""Supplied as JSON on a launch flag — the --agents session form."""
|
|
150
|
+
|
|
151
|
+
storage_kind: Literal["in_memory_flag"] = "in_memory_flag"
|
|
152
|
+
flag: CliFlag
|
|
153
|
+
|
|
154
|
+
def discover(self, base: Path) -> list[CaptureHit]: # noqa: ARG002 — uniform Storage interface
|
|
155
|
+
"""Always empty — a launch-flag definition lives in process memory, not on disk."""
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
Storage = Annotated[
|
|
160
|
+
StandaloneFile | SettingsEntry | HostFrontmatter | InMemoryFlag,
|
|
161
|
+
Field(discriminator="storage_kind"),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- Axis 2: location -----------------------------------------------------------------
|
|
166
|
+
# A concrete place a definition can live: a scope, how it is shared, the root it sits under,
|
|
167
|
+
# and the storage shape. Scope is a property of location, not the organizing axis. Precedence
|
|
168
|
+
# is NOT a property of a scope: it differs by artifact kind, so it lives in the order each
|
|
169
|
+
# artifact lists its locations (Axis 3), not in a table here.
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Location(FrozenModel):
|
|
173
|
+
"""Where a definition lives, and what that implies — scope, shareability, storage shape."""
|
|
174
|
+
|
|
175
|
+
scope: ScopeKind
|
|
176
|
+
shareable: Shareability
|
|
177
|
+
root: ScopeRoot
|
|
178
|
+
storage: Storage
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --- Axis 3: artifact -----------------------------------------------------------------
|
|
182
|
+
# What is stored. Each kind declares the locations it can live in — listed highest-precedence
|
|
183
|
+
# first, so the order *is* the name-collision precedence. That order differs by kind: subagents
|
|
184
|
+
# rank project over user, skills and commands the reverse; only subagents have the --agents
|
|
185
|
+
# session scope; commands have no managed/enterprise location.
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _standalone(
|
|
189
|
+
subdir: ArtifactSubdir, glob: GlobPattern, name_from: Literal["stem", "parent"]
|
|
190
|
+
) -> StandaloneFile:
|
|
191
|
+
"""A standalone-file storage shape under `<root>/<subdir>` matching `glob`."""
|
|
192
|
+
return StandaloneFile(subdir=subdir, glob=glob, name_from=name_from)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _managed(shape: StandaloneFile) -> Location:
|
|
196
|
+
"""The managed (admin) location for `shape`, under `<managed>/.claude`."""
|
|
197
|
+
return Location(
|
|
198
|
+
scope=ScopeKind.managed,
|
|
199
|
+
shareable=Shareability.admin,
|
|
200
|
+
root="<managed>/.claude",
|
|
201
|
+
storage=shape,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _project(shape: StandaloneFile) -> Location:
|
|
206
|
+
"""The project (committed) location for `shape`, under `.claude`."""
|
|
207
|
+
return Location(
|
|
208
|
+
scope=ScopeKind.project, shareable=Shareability.committed, root=".claude", storage=shape
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _user(shape: StandaloneFile) -> Location:
|
|
213
|
+
"""The user (machine-local) location for `shape`, under `~/.claude`."""
|
|
214
|
+
return Location(
|
|
215
|
+
scope=ScopeKind.user, shareable=Shareability.machine_local, root="~/.claude", storage=shape
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _plugin(shape: StandaloneFile) -> Location:
|
|
220
|
+
"""The plugin (bundled) location for `shape`, under the plugin root."""
|
|
221
|
+
return Location(
|
|
222
|
+
scope=ScopeKind.plugin, shareable=Shareability.bundled, root="<plugin>", storage=shape
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _session() -> Location:
|
|
227
|
+
"""The session (ephemeral) `--agents` location — an in-memory flag, no disk root."""
|
|
228
|
+
return Location(
|
|
229
|
+
scope=ScopeKind.session,
|
|
230
|
+
shareable=Shareability.ephemeral,
|
|
231
|
+
root="(--agents)",
|
|
232
|
+
storage=InMemoryFlag(flag="--agents"),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_AGENT_FILE = _standalone("agents", "**/*.md", "stem")
|
|
237
|
+
_SKILL_FILE = _standalone("skills", "**/SKILL.md", "parent")
|
|
238
|
+
_COMMAND_FILE = _standalone("commands", "**/*.md", "stem")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Subagent(FrozenModel):
|
|
242
|
+
"""The subagent artifact `.md` files, on a collision; locations are highest-precedence first
|
|
243
|
+
(managed, then the `--agents` session scope, then project over user, then plugin)."""
|
|
244
|
+
|
|
245
|
+
kind: Literal[ArtifactKind.agent] = ArtifactKind.agent
|
|
246
|
+
resolution: Literal[ResolutionMode.collision] = ResolutionMode.collision
|
|
247
|
+
locations: list[Location] = [
|
|
248
|
+
_managed(_AGENT_FILE),
|
|
249
|
+
_session(),
|
|
250
|
+
_project(_AGENT_FILE),
|
|
251
|
+
_user(_AGENT_FILE),
|
|
252
|
+
_plugin(_AGENT_FILE),
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class Skill(FrozenModel):
|
|
257
|
+
"""The skill `SKILL.md` files, on a collision; locations are highest-precedence first
|
|
258
|
+
(managed, then user over project — the reverse of subagents — then plugin)."""
|
|
259
|
+
|
|
260
|
+
kind: Literal[ArtifactKind.skill] = ArtifactKind.skill
|
|
261
|
+
resolution: Literal[ResolutionMode.collision] = ResolutionMode.collision
|
|
262
|
+
locations: list[Location] = [
|
|
263
|
+
_managed(_SKILL_FILE),
|
|
264
|
+
_user(_SKILL_FILE),
|
|
265
|
+
_project(_SKILL_FILE),
|
|
266
|
+
_plugin(_SKILL_FILE),
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Command(FrozenModel):
|
|
271
|
+
"""The command `.md` files, on a collision; locations are highest-precedence first (user,
|
|
272
|
+
project, plugin) — commands have no managed/enterprise location."""
|
|
273
|
+
|
|
274
|
+
kind: Literal[ArtifactKind.command] = ArtifactKind.command
|
|
275
|
+
resolution: Literal[ResolutionMode.collision] = ResolutionMode.collision
|
|
276
|
+
locations: list[Location] = [
|
|
277
|
+
_user(_COMMAND_FILE),
|
|
278
|
+
_project(_COMMAND_FILE),
|
|
279
|
+
_plugin(_COMMAND_FILE),
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class Hook(FrozenModel):
|
|
284
|
+
"""A hook is never a file of its own: it is a settings entry, a bundled hooks.json, or a
|
|
285
|
+
key in a host component's frontmatter. Its scope follows where it is written."""
|
|
286
|
+
|
|
287
|
+
kind: Literal[ArtifactKind.hook] = ArtifactKind.hook
|
|
288
|
+
resolution: Literal[ResolutionMode.additive] = ResolutionMode.additive
|
|
289
|
+
locations: list[Location] = [
|
|
290
|
+
Location(
|
|
291
|
+
scope=ScopeKind.user,
|
|
292
|
+
shareable=Shareability.machine_local,
|
|
293
|
+
root="~/.claude",
|
|
294
|
+
storage=SettingsEntry(file="settings.json", key="hooks"),
|
|
295
|
+
),
|
|
296
|
+
Location(
|
|
297
|
+
scope=ScopeKind.project,
|
|
298
|
+
shareable=Shareability.committed,
|
|
299
|
+
root=".claude",
|
|
300
|
+
storage=SettingsEntry(file="settings.json", key="hooks"),
|
|
301
|
+
),
|
|
302
|
+
Location(
|
|
303
|
+
scope=ScopeKind.project,
|
|
304
|
+
shareable=Shareability.gitignored,
|
|
305
|
+
root=".claude",
|
|
306
|
+
storage=SettingsEntry(file="settings.local.json", key="hooks"),
|
|
307
|
+
),
|
|
308
|
+
Location(
|
|
309
|
+
scope=ScopeKind.managed,
|
|
310
|
+
shareable=Shareability.admin,
|
|
311
|
+
root="<managed>",
|
|
312
|
+
storage=SettingsEntry(file="managed-settings.json", key="hooks"),
|
|
313
|
+
),
|
|
314
|
+
Location(
|
|
315
|
+
scope=ScopeKind.plugin,
|
|
316
|
+
shareable=Shareability.bundled,
|
|
317
|
+
root="<plugin>",
|
|
318
|
+
storage=SettingsEntry(file="hooks/hooks.json", key=None),
|
|
319
|
+
),
|
|
320
|
+
Location(
|
|
321
|
+
scope=ScopeKind.component,
|
|
322
|
+
shareable=Shareability.in_component,
|
|
323
|
+
root="(host)",
|
|
324
|
+
storage=HostFrontmatter(key="hooks"),
|
|
325
|
+
),
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
Artifact = Annotated[Subagent | Skill | Command | Hook, Field(discriminator="kind")]
|
|
330
|
+
"""A scoped artifact kind, dispatched on `kind`."""
|