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,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`."""