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
capdisc/discovery.py ADDED
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from pathlib import Path
5
+
6
+ from pydantic import TypeAdapter, ValidationError
7
+
8
+ from .base import InputModel
9
+ from .catalog import (
10
+ CATALOG_ID_MAX,
11
+ BuiltinTool,
12
+ Catalog,
13
+ CatalogMcpServer,
14
+ CatalogSkill,
15
+ CatalogTool,
16
+ SkillRef,
17
+ catalog_id,
18
+ )
19
+ from .frontmatter import read_frontmatter
20
+ from .scope import ArtifactKind, ScopeInventory, ScopeRoots
21
+
22
+ _SKILL_REF: TypeAdapter[SkillRef] = TypeAdapter(SkillRef) # one place a skill name becomes a ref
23
+ _DESCRIPTION_SCAN_MAX = 800
24
+
25
+ # The built-in Claude Code tools any subagent may be granted. read_only / needs_network
26
+ # record each tool's real behaviour as catalog metadata (surfaced in the inventory report).
27
+ BUILTIN_TOOLS: list[CatalogTool] = [
28
+ CatalogTool(
29
+ id="builtin.read",
30
+ ref=BuiltinTool.read.value,
31
+ description="Read a file from the filesystem.",
32
+ tags=["file", "files", "read", "open", "load", "view", "contents"],
33
+ read_only=True,
34
+ ),
35
+ CatalogTool(
36
+ id="builtin.write",
37
+ ref=BuiltinTool.write.value,
38
+ description="Write or overwrite a file on the filesystem.",
39
+ tags=["file", "files", "write", "create", "generate", "save", "output", "produce"],
40
+ read_only=False,
41
+ ),
42
+ CatalogTool(
43
+ id="builtin.edit",
44
+ ref=BuiltinTool.edit.value,
45
+ description="Edit a file by replacing an exact string.",
46
+ tags=["file", "files", "edit", "modify", "change", "replace", "update", "line"],
47
+ read_only=False,
48
+ ),
49
+ CatalogTool(
50
+ id="builtin.glob",
51
+ ref=BuiltinTool.glob.value,
52
+ description="Find files by glob pattern.",
53
+ tags=["file", "files", "find", "search", "glob", "pattern", "match", "locate"],
54
+ read_only=True,
55
+ ),
56
+ CatalogTool(
57
+ id="builtin.grep",
58
+ ref=BuiltinTool.grep.value,
59
+ description="Search file contents with a regular expression.",
60
+ tags=["search", "grep", "find", "pattern", "regex", "contents", "match", "code"],
61
+ read_only=True,
62
+ ),
63
+ CatalogTool(
64
+ id="builtin.bash",
65
+ ref=BuiltinTool.bash.value,
66
+ description="Run a shell command in the workspace.",
67
+ tags=["shell", "run", "execute", "command", "build", "script", "terminal"],
68
+ read_only=False,
69
+ ),
70
+ CatalogTool(
71
+ id="builtin.web_fetch",
72
+ ref=BuiltinTool.web_fetch.value,
73
+ description="Fetch and read a web page over the network.",
74
+ tags=["web", "fetch", "network", "url", "page", "download", "http"],
75
+ read_only=True,
76
+ needs_network=True,
77
+ ),
78
+ CatalogTool(
79
+ id="builtin.web_search",
80
+ ref=BuiltinTool.web_search.value,
81
+ description="Search the web and read result snippets.",
82
+ tags=["web", "search", "network", "query", "results", "internet"],
83
+ read_only=True,
84
+ needs_network=True,
85
+ ),
86
+ CatalogTool(
87
+ id="builtin.task",
88
+ ref=BuiltinTool.task.value,
89
+ description="Spawn a subagent to handle a delegated task.",
90
+ tags=["agent", "delegate", "subagent", "spawn", "task"],
91
+ read_only=False,
92
+ ),
93
+ ]
94
+
95
+
96
+ class _SkillFrontmatter(InputModel):
97
+ name: str | None = None
98
+ description: str | None = None
99
+ tags: list[str] = []
100
+
101
+
102
+ def _parse_frontmatter(text: str) -> _SkillFrontmatter | None:
103
+ """Parse a SKILL.md's YAML frontmatter into its typed fields.
104
+
105
+ Args:
106
+ text: The full SKILL.md body, frontmatter and all.
107
+
108
+ Returns:
109
+ The parsed frontmatter, or None when there is no frontmatter or it doesn't validate.
110
+ """
111
+ data = read_frontmatter(text)
112
+ if data is None:
113
+ return None
114
+ try:
115
+ return _SkillFrontmatter.model_validate(data)
116
+ except ValidationError:
117
+ return None
118
+
119
+
120
+ def _disambiguated_ids(ref: str, count: int) -> list[str]:
121
+ """Mint `count` distinct ids for skills sharing `ref`: the bare id, then `-2`, `-3`, ….
122
+
123
+ Args:
124
+ ref: The skill ref the ids are minted for.
125
+ count: How many colliding skills share this ref.
126
+
127
+ Returns:
128
+ `count` distinct ids, in the order a caller's own content-sorted list should be assigned
129
+ them — this function only mints the id strings; which skill gets which is the caller's
130
+ choice. The base is trimmed so each suffix survives the id length cap; if that trim
131
+ happens to reproduce an id already minted (the base's own tail already reads e.g. "-2"),
132
+ one more character is trimmed until the candidate is unique.
133
+ """
134
+ base = catalog_id("skill", ref)
135
+ ids = [base]
136
+ seen = {base}
137
+ for suffix in range(2, count + 1):
138
+ tail = f"-{suffix}"
139
+ trim = len(tail)
140
+ candidate = base[: CATALOG_ID_MAX - trim] + tail
141
+ while candidate in seen and trim < len(base):
142
+ trim += 1
143
+ candidate = base[: CATALOG_ID_MAX - trim] + tail
144
+ seen.add(candidate)
145
+ ids.append(candidate)
146
+ return ids
147
+
148
+
149
+ def skill_ref(skill_md: Path) -> SkillRef | None:
150
+ """Resolve the `SkillRef` a SKILL.md file declares.
151
+
152
+ Shared by the skill index and plugin discovery so a plugin's declared skill refs match the
153
+ refs of the indexed skills they point at — both go through the same `SkillRef` normalization.
154
+
155
+ Args:
156
+ skill_md: Path to a `SKILL.md` file; read with errors ignored.
157
+
158
+ Returns:
159
+ The ref from the frontmatter `name` (falling back to the parent dir name), or None when
160
+ the file is unreadable, lacks a usable description, or the name won't normalize.
161
+ """
162
+ try:
163
+ text = skill_md.read_text(encoding="utf-8", errors="ignore")
164
+ except OSError:
165
+ return None
166
+ frontmatter = _parse_frontmatter(text)
167
+ if frontmatter is None or not frontmatter.description:
168
+ return None
169
+ try:
170
+ return _SKILL_REF.validate_python(frontmatter.name or skill_md.parent.name)
171
+ except ValidationError:
172
+ return None
173
+
174
+
175
+ def _card(skill_id: str, ref: str, frontmatter: _SkillFrontmatter) -> CatalogSkill | None:
176
+ """Assemble one `CatalogSkill` from a skill's id, ref, and parsed frontmatter.
177
+
178
+ Args:
179
+ skill_id: The unique catalog id minted for this skill.
180
+ ref: The skill's normalized ref.
181
+ frontmatter: Its parsed frontmatter; the description is truncated to the scan cap and
182
+ an absent one becomes empty.
183
+
184
+ Returns:
185
+ The card, or None if the fields don't validate.
186
+ """
187
+ try:
188
+ return CatalogSkill(
189
+ id=skill_id,
190
+ ref=ref,
191
+ description=(frontmatter.description or "")[:_DESCRIPTION_SCAN_MAX],
192
+ tags=frontmatter.tags,
193
+ )
194
+ except ValidationError:
195
+ return None
196
+
197
+
198
+ def scan_indexed_skills(roots: ScopeRoots) -> list[tuple[CatalogSkill, Path]]:
199
+ """Index every SKILL.md the scope inventory captures under `roots` into `(card, path)` pairs.
200
+
201
+ The inventory owns where skills live (project walk-up, user, managed, plugin, nested); this
202
+ classifies each capture — parsing its contents, deduping a skill cached across locations by
203
+ (name, description), and disambiguating colliding ids.
204
+
205
+ Args:
206
+ roots: The scope roots to scan; passed straight to `ScopeInventory.scan`.
207
+
208
+ Returns:
209
+ One `(card, path)` per distinct skill — a stable id and the path to load its body —
210
+ skipping captures with no description and dropping later duplicates of a (name,
211
+ description) already seen.
212
+ """
213
+ seen: set[tuple[str, str]] = set()
214
+ by_ref: dict[str, list[tuple[_SkillFrontmatter, Path]]] = {}
215
+ for captured in ScopeInventory.scan(roots).artifacts:
216
+ if captured.kind is not ArtifactKind.skill:
217
+ continue
218
+ frontmatter = _parse_frontmatter(captured.contents)
219
+ if frontmatter is None or not frontmatter.description:
220
+ continue
221
+ name = frontmatter.name or captured.path.parent.name
222
+ key = (name, frontmatter.description)
223
+ if key in seen:
224
+ continue
225
+ try:
226
+ ref = _SKILL_REF.validate_python(name)
227
+ except ValidationError:
228
+ continue
229
+ seen.add(key)
230
+ by_ref.setdefault(ref, []).append((frontmatter, captured.path))
231
+
232
+ indexed: list[tuple[CatalogSkill, Path]] = []
233
+ for ref, entries in by_ref.items():
234
+ # sorted by content, never by scan order — the id a colliding skill gets must depend
235
+ # only on which skills exist, not on which scope root or filesystem order happened to
236
+ # surface it first; otherwise a consumer that persisted the id could silently resolve
237
+ # to a different skill after an unrelated scan-root change.
238
+ ordered = sorted(entries, key=lambda item: (item[0].description or "", str(item[1])))
239
+ for skill_id, (frontmatter, path) in zip(
240
+ _disambiguated_ids(ref, len(ordered)), ordered, strict=True
241
+ ):
242
+ card = _card(skill_id, ref, frontmatter)
243
+ if card is None:
244
+ continue
245
+ indexed.append((card, path))
246
+ return indexed
247
+
248
+
249
+ def scan_skills(roots: ScopeRoots) -> list[CatalogSkill]:
250
+ """Index the skill cards captured under `roots`, discarding their on-disk paths.
251
+
252
+ Args:
253
+ roots: The scope roots to scan.
254
+
255
+ Returns:
256
+ One card per distinct skill, skipping malformed ones.
257
+ """
258
+ return [card for card, _ in scan_indexed_skills(roots)]
259
+
260
+
261
+ def scan_environment(
262
+ roots: ScopeRoots,
263
+ mcp_servers: Sequence[CatalogMcpServer] = (),
264
+ ) -> Catalog:
265
+ """Build a live catalog from the captured skills and connected MCP servers — the entries
266
+ recall ranks against. Built-in tools are not retrieved by text (their descriptions never match
267
+ a task's goal language), so they are not in the catalog; every generated agent gets the fixed
268
+ `DEFAULT_TOOLS` set at selection time instead.
269
+
270
+ Args:
271
+ roots: The scope roots to scan for skills.
272
+ mcp_servers: Connected MCP server cards to include; pass `enumerate_mcp_servers()` to
273
+ harvest them. The empty default keeps the scan hermetic.
274
+
275
+ Returns:
276
+ A `Catalog` of the captured skills and `mcp_servers`.
277
+ """
278
+ return Catalog(entries=[*scan_skills(roots), *mcp_servers])
capdisc/frontmatter.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import yaml
6
+ from pydantic import JsonValue, TypeAdapter, ValidationError
7
+
8
+ # \r?\n: CRLF-authored files (git core.autocrlf) must not silently lose their frontmatter.
9
+ _FRONTMATTER = re.compile(r"^---\r?\n(.*?)\r?\n---", re.DOTALL)
10
+ _BOM = "\ufeff"
11
+ _MAPPING: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue])
12
+
13
+
14
+ def load[T](text: str, shape: TypeAdapter[T]) -> T | None:
15
+ """Parse YAML or JSON text into `shape`.
16
+
17
+ No format sniffing is needed: YAML is a superset of JSON, so one `yaml.safe_load` reads both.
18
+ When the text is known to be JSON, prefer `shape.validate_json(text)` — stricter and faster.
19
+
20
+ Args:
21
+ text: The YAML/JSON source.
22
+ shape: The adapter to validate the parsed value into.
23
+
24
+ Returns:
25
+ The validated value, or None if `text` is neither valid YAML/JSON nor that shape.
26
+ """
27
+ try:
28
+ return shape.validate_python(yaml.safe_load(text))
29
+ except (yaml.YAMLError, ValidationError, RecursionError):
30
+ # RecursionError: deeply nested YAML in untrusted repo content must skip itself,
31
+ # not abort the scan.
32
+ return None
33
+
34
+
35
+ def read_frontmatter(text: str) -> dict[str, JsonValue] | None:
36
+ """Extract the leading `--- … ---` frontmatter block as a mapping.
37
+
38
+ The single frontmatter-parsing boundary; callers validate the mapping into their own typed
39
+ model.
40
+
41
+ Args:
42
+ text: The document body, which may open with a frontmatter block.
43
+
44
+ Returns:
45
+ The frontmatter as a mapping, or None when the text has no such block or it isn't a
46
+ mapping.
47
+ """
48
+ match = _FRONTMATTER.match(text.removeprefix(_BOM))
49
+ return load(match.group(1), _MAPPING) if match else None
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from ..catalog import McpToolName
4
+ from .schema import (
5
+ EVENT_HANDLER_TYPES,
6
+ AgentHook,
7
+ CommandHook,
8
+ HandlerType,
9
+ HookAdapter,
10
+ HookConfig,
11
+ HookEvent,
12
+ HookHandler,
13
+ HttpHook,
14
+ MatcherGroup,
15
+ McpToolHook,
16
+ PromptHook,
17
+ )
18
+ from .types import (
19
+ EnvVarName,
20
+ HeaderName,
21
+ HeaderValue,
22
+ HookCondition,
23
+ HookMatcherPattern,
24
+ HookModel,
25
+ HookPrompt,
26
+ HookShell,
27
+ HookTimeout,
28
+ HookUrl,
29
+ ShellArg,
30
+ ShellCommand,
31
+ StatusMessage,
32
+ )
33
+
34
+ __all__ = [
35
+ "EVENT_HANDLER_TYPES",
36
+ "AgentHook",
37
+ "CommandHook",
38
+ "EnvVarName",
39
+ "HandlerType",
40
+ "HeaderName",
41
+ "HeaderValue",
42
+ "HookAdapter",
43
+ "HookCondition",
44
+ "HookConfig",
45
+ "HookEvent",
46
+ "HookHandler",
47
+ "HookMatcherPattern",
48
+ "HookModel",
49
+ "HookPrompt",
50
+ "HookShell",
51
+ "HookTimeout",
52
+ "HookUrl",
53
+ "HttpHook",
54
+ "MatcherGroup",
55
+ "McpToolHook",
56
+ "McpToolName",
57
+ "PromptHook",
58
+ "ShellArg",
59
+ "ShellCommand",
60
+ "StatusMessage",
61
+ ]
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Annotated, Literal
5
+
6
+ from pydantic import Field, JsonValue, RootModel, TypeAdapter, model_validator
7
+
8
+ from ..base import FrozenWireModel
9
+ from ..catalog import McpServerRef, McpToolName
10
+ from .types import (
11
+ AsyncRewake,
12
+ EnvVarName,
13
+ HeaderName,
14
+ HeaderValue,
15
+ HookCondition,
16
+ HookMatcherPattern,
17
+ HookModel,
18
+ HookPrompt,
19
+ HookShell,
20
+ HookTimeout,
21
+ HookUrl,
22
+ RunAsync,
23
+ RunOnce,
24
+ ShellArg,
25
+ ShellCommand,
26
+ StatusMessage,
27
+ )
28
+
29
+
30
+ class _Handler(FrozenWireModel):
31
+ """Fields every hook handler carries, whatever its `type`."""
32
+
33
+ timeout: HookTimeout | None = None
34
+ status_message: StatusMessage | None = None
35
+ once: RunOnce = False
36
+ condition: HookCondition | None = Field(default=None, alias="if")
37
+
38
+
39
+ class CommandHook(_Handler):
40
+ """Runs a shell command, or spawns an executable directly when `args` is set."""
41
+
42
+ type: Literal["command"] = "command"
43
+ command: ShellCommand
44
+ args: list[ShellArg] = []
45
+ run_async: RunAsync = Field(default=False, alias="async")
46
+ async_rewake: AsyncRewake = False
47
+ shell: HookShell | None = None
48
+
49
+ @model_validator(mode="after")
50
+ def _async_rewake_implies_async(self) -> CommandHook:
51
+ """`asyncRewake` implies `async` (per the hooks docs), so `run_async` stays truthful as
52
+ 'runs in background': any hook with `asyncRewake` set is forced async."""
53
+ if self.async_rewake and not self.run_async:
54
+ return self.model_copy(update={"run_async": True})
55
+ return self
56
+
57
+
58
+ class HttpHook(_Handler):
59
+ """POSTs the event JSON to an endpoint and reads the decision from the response body."""
60
+
61
+ type: Literal["http"] = "http"
62
+ url: HookUrl
63
+ headers: dict[HeaderName, HeaderValue] = {}
64
+ allowed_env_vars: list[EnvVarName] = []
65
+
66
+
67
+ class McpToolHook(_Handler):
68
+ """Calls a tool on an already-connected MCP server."""
69
+
70
+ type: Literal["mcp_tool"] = "mcp_tool"
71
+ server: McpServerRef
72
+ tool: McpToolName
73
+ input: dict[str, JsonValue] = {}
74
+
75
+
76
+ class _LlmHook(_Handler):
77
+ """Shared fields of the LLM-evaluated hooks: a prompt sent to a model for an allow/block
78
+ decision. Prompt and agent hooks differ only in whether that model gets tool access."""
79
+
80
+ prompt: HookPrompt
81
+ model: HookModel | None = None
82
+
83
+
84
+ class PromptHook(_LlmHook):
85
+ """Single-turn LLM evaluation that allows or blocks the action."""
86
+
87
+ type: Literal["prompt"] = "prompt"
88
+ continue_on_block: bool = False
89
+
90
+
91
+ class AgentHook(_LlmHook):
92
+ """Multi-turn agentic verifier with tool access (experimental)."""
93
+
94
+ type: Literal["agent"] = "agent"
95
+
96
+
97
+ HookHandler = Annotated[
98
+ CommandHook | HttpHook | McpToolHook | PromptHook | AgentHook,
99
+ Field(discriminator="type"),
100
+ ]
101
+
102
+ HookAdapter: TypeAdapter[HookHandler] = TypeAdapter(HookHandler)
103
+
104
+ HandlerType = Literal["command", "http", "mcp_tool", "prompt", "agent"]
105
+
106
+
107
+ class HookEvent(StrEnum):
108
+ """An event a hook can fire on. Which handler `type`s an event accepts varies — see
109
+ `EVENT_HANDLER_TYPES`."""
110
+
111
+ pre_tool_use = "PreToolUse"
112
+ post_tool_use = "PostToolUse"
113
+ post_tool_use_failure = "PostToolUseFailure"
114
+ post_tool_batch = "PostToolBatch"
115
+ permission_denied = "PermissionDenied"
116
+ permission_request = "PermissionRequest"
117
+ user_prompt_submit = "UserPromptSubmit"
118
+ user_prompt_expansion = "UserPromptExpansion"
119
+ stop = "Stop"
120
+ subagent_stop = "SubagentStop"
121
+ task_completed = "TaskCompleted"
122
+ task_created = "TaskCreated"
123
+ teammate_idle = "TeammateIdle"
124
+ config_change = "ConfigChange"
125
+ cwd_changed = "CwdChanged"
126
+ elicitation = "Elicitation"
127
+ elicitation_result = "ElicitationResult"
128
+ file_changed = "FileChanged"
129
+ instructions_loaded = "InstructionsLoaded"
130
+ notification = "Notification"
131
+ post_compact = "PostCompact"
132
+ pre_compact = "PreCompact"
133
+ session_end = "SessionEnd"
134
+ stop_failure = "StopFailure"
135
+ subagent_start = "SubagentStart"
136
+ worktree_create = "WorktreeCreate"
137
+ worktree_remove = "WorktreeRemove"
138
+ session_start = "SessionStart"
139
+ setup = "Setup"
140
+
141
+
142
+ # Which handler types each event accepts, per the Claude Code hooks docs. Three tiers: all
143
+ # five types, the non-LLM three (no prompt/agent), and command+mcp_tool only.
144
+ # Source: https://code.claude.com/docs/en/hooks (snapshot verified 2026-06). This mirrors a
145
+ # product matrix that shifts between releases — refresh against the docs when events change.
146
+ _ALL_TYPES: frozenset[HandlerType] = frozenset(("command", "http", "mcp_tool", "prompt", "agent"))
147
+ _NO_LLM_TYPES: frozenset[HandlerType] = frozenset(("command", "http", "mcp_tool"))
148
+ _CMD_MCP_TYPES: frozenset[HandlerType] = frozenset(("command", "mcp_tool"))
149
+
150
+ EVENT_HANDLER_TYPES: dict[HookEvent, frozenset[HandlerType]] = {
151
+ HookEvent.session_start: _CMD_MCP_TYPES,
152
+ HookEvent.setup: _CMD_MCP_TYPES,
153
+ **dict.fromkeys(
154
+ (
155
+ HookEvent.config_change,
156
+ HookEvent.cwd_changed,
157
+ HookEvent.elicitation,
158
+ HookEvent.elicitation_result,
159
+ HookEvent.file_changed,
160
+ HookEvent.instructions_loaded,
161
+ HookEvent.notification,
162
+ HookEvent.post_compact,
163
+ HookEvent.pre_compact,
164
+ HookEvent.session_end,
165
+ HookEvent.stop_failure,
166
+ HookEvent.subagent_start,
167
+ HookEvent.worktree_create,
168
+ HookEvent.worktree_remove,
169
+ ),
170
+ _NO_LLM_TYPES,
171
+ ),
172
+ **dict.fromkeys(
173
+ (
174
+ HookEvent.pre_tool_use,
175
+ HookEvent.post_tool_use,
176
+ HookEvent.post_tool_use_failure,
177
+ HookEvent.post_tool_batch,
178
+ HookEvent.permission_denied,
179
+ HookEvent.permission_request,
180
+ HookEvent.user_prompt_submit,
181
+ HookEvent.user_prompt_expansion,
182
+ HookEvent.stop,
183
+ HookEvent.subagent_stop,
184
+ HookEvent.task_completed,
185
+ HookEvent.task_created,
186
+ HookEvent.teammate_idle,
187
+ ),
188
+ _ALL_TYPES,
189
+ ),
190
+ }
191
+
192
+
193
+ class MatcherGroup(FrozenWireModel):
194
+ """One `{matcher, hooks}` entry under an event: the handlers that run for matched subjects."""
195
+
196
+ matcher: HookMatcherPattern = "*"
197
+ hooks: list[HookHandler]
198
+
199
+
200
+ class HookConfig(RootModel[dict[HookEvent, list[MatcherGroup]]]):
201
+ """The value of a `hooks` settings key (or a plugin's hooks.json): events mapped to their
202
+ matcher groups. Enforces that each handler `type` is one its event supports."""
203
+
204
+ @model_validator(mode="after")
205
+ def _enforce_event_support(self) -> HookConfig:
206
+ """Reject any handler whose `type` its event doesn't accept (per `EVENT_HANDLER_TYPES`)."""
207
+ for event, groups in self.root.items():
208
+ allowed = EVENT_HANDLER_TYPES[event]
209
+ for group in groups:
210
+ for handler in group.hooks:
211
+ if handler.type not in allowed:
212
+ raise ValueError(
213
+ f"{handler.type!r} hook is not supported on {event.value} "
214
+ f"(allowed: {sorted(allowed)})"
215
+ )
216
+ return self