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
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
|
+
]
|
capdisc/hooks/schema.py
ADDED
|
@@ -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
|