@ssheleg/make-skill 0.28.0 → 0.29.1
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.
- package/CHANGELOG.md +40 -0
- package/package.json +1 -1
- package/plugins/make-skill/.claude-plugin/plugin.json +1 -1
- package/plugins/make-skill/skills/make-skill/SKILL.md +1 -1
- package/plugins/make-skill/skills/make-skill/references/host-capabilities.md +24 -1
- package/plugins/make-skill/skills/make-skill/scripts/audit_skill.py +170 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
## v0.29.1 — indented plain descriptions are measured correctly
|
|
2
|
+
|
|
3
|
+
The auditor now accepts a plain YAML description whose text begins on the
|
|
4
|
+
next indented line. It folds continuation lines, preserves paragraph breaks,
|
|
5
|
+
and respects comment boundaries instead of reporting a valid description as
|
|
6
|
+
missing. Bare typed values remain subject to the field's string requirement.
|
|
7
|
+
|
|
8
|
+
Unsupported forms in that path produce `FM_SUBSET_UNSUPPORTED`; field checks
|
|
9
|
+
remain unmeasured instead of making a missing-value claim. The parser stays
|
|
10
|
+
dependency-free, with an installed full YAML parser used only as an optional
|
|
11
|
+
oracle. The regression covers valid strings, actual missing/invalid values,
|
|
12
|
+
length limits, metadata maps, comments and the no-PyYAML path.
|
|
13
|
+
|
|
14
|
+
## v0.29.0 — the standard now reads a hook key the host ignores
|
|
15
|
+
|
|
16
|
+
Claude Code 2.1.270 began printing `hooks.json: unknown key "if" in
|
|
17
|
+
hooks.PreToolUse[1] ignored` at every session start. The key had been there since
|
|
18
|
+
agent-sync 0.1.0, and the same shape sat in the hooks template `task-pipeline`
|
|
19
|
+
exports into consumer projects — where the documentation gate then ran on **every**
|
|
20
|
+
Bash call rather than on commits. Nothing caught it: not nine member validators, not
|
|
21
|
+
this auditor, and not `claude plugin validate --strict`, which accepts the file.
|
|
22
|
+
|
|
23
|
+
- **`HOOKS_SCHEMA`** reads the plugin's `hooks/hooks.json` from a skill at
|
|
24
|
+
`<plugin>/skills/<name>` and refuses any key outside the set its level accepts —
|
|
25
|
+
a matcher group takes `matcher` + `hooks`; a `command` handler takes `type`,
|
|
26
|
+
`command`, `args`, `if`, `shell`, `timeout`, `statusMessage`, `once`, `async`,
|
|
27
|
+
`asyncRewake` and three `@internal` keys. Both sets are read out of the 2.1.270
|
|
28
|
+
binary's schema rather than from prose. A manifest that does not parse is a GAP
|
|
29
|
+
(the host then loads no hooks at all); a plugin with no manifest passes and says
|
|
30
|
+
so; a `prompt`/`agent`/`http`/`mcp_tool` handler is **declared unchecked**,
|
|
31
|
+
because only the command handler's key set was measured.
|
|
32
|
+
- **`BUNDLE_NESTED` stops reporting `__pycache__`.** A byte-compile cache appears
|
|
33
|
+
the moment a test imports a shipped module; it is gitignored and npm-excluded in
|
|
34
|
+
every member, and three of them were carrying the gap for a directory that never
|
|
35
|
+
ships.
|
|
36
|
+
- `references/host-capabilities.md` states both key sets, where `if` lives, the
|
|
37
|
+
loader's exact warning, and that `validate --strict` is not the gate for this.
|
|
38
|
+
|
|
39
|
+
Every shipped skill in the family passes: 28 of 28 at `0 GAP`, now `19 PASS`.
|
|
40
|
+
|
|
1
41
|
## v0.28.0 — the house audit measures the token budget instead of estimating it
|
|
2
42
|
|
|
3
43
|
Sherlock external-v3 (13 findings) plus the enterprise handoff (PR #18) and the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/make-skill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Create, retrofit, audit, and ship agent skills & Claude Code plugins the proven ssheleg way \u2014 conformance to the Agent Skills open standard AND Anthropic's platform rules (front-matter limits, disclosure budgets, per-surface runtime limits, the Skills API, evals) plus the Claude Code plugin reference (manifest schemas, component layout, claude plugin validate --strict), marketplace repo layout, version sync, validator + CI, multi-channel distribution (plugin, vercel skills CLI, npx, Cursor), npm gotchas, the review checklist for third-party skills, and MCP / A2A rules for protocol-connected skills. This package is the installer CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"skill",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "make-skill",
|
|
4
4
|
"displayName": "Make Skill",
|
|
5
5
|
"description": "Create, retrofit, audit, and ship agent skills & Claude Code plugins the proven ssheleg way: conformance to the Agent Skills open standard, Anthropic's platform rules (surfaces, Skills API, evals) and the Claude Code plugin reference, marketplace repo layout, version sync, validator + CI, multi-channel distribution (plugin, vercel skills CLI, npx, Cursor), npm gotchas, end-to-end first publish, the review checklist for third-party skills, plus MCP / A2A references for protocol-connected skills.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.29.1",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "ssheleg",
|
|
9
9
|
"url": "https://x.com/sshlg93"
|
|
@@ -5,7 +5,7 @@ license: MIT
|
|
|
5
5
|
compatibility: Authoring works on any agent. The bundled scripts/ need python3. Publishing steps need git, gh, node and npm; the plugin gates need the claude CLI. Not usable on the Claude API surface, which has no network and no runtime package install.
|
|
6
6
|
metadata:
|
|
7
7
|
author: ssheleg
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.29.1"
|
|
9
9
|
homepage: https://github.com/ssheleg/make-skill
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -83,7 +83,28 @@ an unanchored regex. Tool names are what you match (`Bash`, `Write`,
|
|
|
83
83
|
`mcp__server__tool`). A plugin's OWN MCP server is matched as
|
|
84
84
|
`mcp__plugin_<plugin>_<server>__<tool>`, and an `mcp_tool` hook names the server
|
|
85
85
|
as `plugin:<plugin>:<server>` — the bare key never fires. Narrow further with
|
|
86
|
-
`if`, a permission rule: `"if": "Bash(git commit *)"
|
|
86
|
+
`if`, a permission rule: `"if": "Bash(git commit *)"` — **inside the handler
|
|
87
|
+
object**, beside `type` and `command`.
|
|
88
|
+
|
|
89
|
+
**Two key sets, and putting a key in the wrong one is silent.** Read out of the
|
|
90
|
+
2.1.270 binary's schema: a matcher group takes `matcher` and `hooks`, nothing
|
|
91
|
+
else; a `command` handler takes `type`, `command`, `args`, `if`, `shell`,
|
|
92
|
+
`timeout`, `statusMessage`, `once`, `async`, `asyncRewake` (plus three
|
|
93
|
+
`@internal` keys). Anything outside its set is **ignored** — the hook still runs,
|
|
94
|
+
with the key doing nothing. Before 2.1.270 nothing said so at all; from it the
|
|
95
|
+
loader prints once per session:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Plugin <name>: hooks.json: unknown key "if" in hooks.PreToolUse[1] ignored
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`claude plugin validate --strict` accepts the file either way, so a skill audit
|
|
102
|
+
is the only gate that catches this before a session starts — `HOOKS_SCHEMA` in
|
|
103
|
+
`scripts/audit_skill.py` reads both sets. The family paid for that check: `if`
|
|
104
|
+
sat beside `matcher` in `agent-sync` from 0.1.0 to 1.20.0 (harmless — its guard
|
|
105
|
+
narrowed to a commit with its own parser), and in the hooks template
|
|
106
|
+
`task-pipeline` tells projects to paste into `settings.json`, where the
|
|
107
|
+
documentation gate then ran on **every** Bash call instead of on commits.
|
|
87
108
|
|
|
88
109
|
**Exit codes are the contract:**
|
|
89
110
|
|
|
@@ -245,6 +266,8 @@ what the agent reads at the exact moment something is missing.
|
|
|
245
266
|
- [ ] Hook scripts exit 0 silently on "not mine" and on a missing interpreter
|
|
246
267
|
- [ ] `PostToolUse` advises (`systemMessage`), `PreToolUse` blocks — not the reverse
|
|
247
268
|
- [ ] Hook commands quote `"${CLAUDE_PLUGIN_ROOT}"` and set a `timeout`
|
|
269
|
+
- [ ] Every key is in the set its level accepts — a filter goes on the handler,
|
|
270
|
+
never beside `matcher`, where the host ignores it without failing
|
|
248
271
|
- [ ] Hook scripts are executable, have a shebang, and need no `jq`
|
|
249
272
|
- [ ] No command named after a skill — or the collision is on a recorded, dated
|
|
250
273
|
exception list (see *Commands*); every `argument-hint` quoted
|
|
@@ -247,6 +247,26 @@ TIME_BRANCH_RE = re.compile(
|
|
|
247
247
|
r"(?:january|february|march|april|may|june|july|august|september|october|"
|
|
248
248
|
r"november|december|\d{4})\b", re.I)
|
|
249
249
|
BUNDLE_DIRS = ("references", "scripts", "assets")
|
|
250
|
+
# A byte-compile cache is not a bundled directory: `__pycache__` appears the moment a
|
|
251
|
+
# test imports a shipped module, is gitignored and npm-excluded in every member, and
|
|
252
|
+
# three of them were reporting BUNDLE_NESTED for a directory that never ships.
|
|
253
|
+
BUNDLE_IGNORE = ("__pycache__",)
|
|
254
|
+
|
|
255
|
+
# Claude Code's hooks schema, read out of the 2.1.270 binary rather than from the
|
|
256
|
+
# documentation: `Rt()` is the matcher GROUP and `Td()` the handler union. A key outside
|
|
257
|
+
# these sets is IGNORED — silently before 2.1.270, and from it announced once per session
|
|
258
|
+
# as `Plugin <name>: hooks.json: unknown key "<k>" in hooks.<Event>[<i>] ignored`. It is
|
|
259
|
+
# not a syntax error, `claude plugin validate --strict` passes the file, and the hook goes
|
|
260
|
+
# on running with the filter absent. agent-sync shipped `"if"` beside `"matcher"` from
|
|
261
|
+
# 0.1.0 to 1.20.0 and task-pipeline exported the same shape in the template it tells
|
|
262
|
+
# projects to paste into `settings.json`, where the gate then ran on every Bash call.
|
|
263
|
+
HOOK_GROUP_KEYS = {"matcher", "hooks"}
|
|
264
|
+
# The `command` handler. `if` is HERE — beside `type` and `command`, never beside
|
|
265
|
+
# `matcher`. The last three are marked `@internal` in the schema and are accepted.
|
|
266
|
+
HOOK_COMMAND_KEYS = {
|
|
267
|
+
"type", "command", "args", "if", "shell", "timeout", "statusMessage", "once",
|
|
268
|
+
"async", "asyncRewake", "rewakeMessage", "rewakeSummary", "cloud",
|
|
269
|
+
}
|
|
250
270
|
|
|
251
271
|
# A file in a publishable payload whose name reads as a credential. Fixtures and
|
|
252
272
|
# test data are the common carriers; the auditor names it rather than shipping it.
|
|
@@ -283,7 +303,8 @@ def parse_frontmatter(text):
|
|
|
283
303
|
"""A STRICT stdlib precheck of an explicitly bounded YAML subset — NOT full
|
|
284
304
|
YAML, and it does not pretend to be (FIX-MS-02.01).
|
|
285
305
|
|
|
286
|
-
The supported subset: top-level scalars (quoted or plain
|
|
306
|
+
The supported subset: top-level scalars (quoted or plain, including a plain
|
|
307
|
+
value starting on the next indented line), block scalars
|
|
287
308
|
(`>`/`|` and their chomping variants), inline flow sequences (`[a, b]`),
|
|
288
309
|
and ONE nested map. Everything else is out of subset and must be caught,
|
|
289
310
|
never waved through — a precheck that silently accepts what the real
|
|
@@ -305,12 +326,34 @@ def parse_frontmatter(text):
|
|
|
305
326
|
for a skill the Skills API rejects on upload (2026-08-16, B-63).
|
|
306
327
|
"""
|
|
307
328
|
parse_frontmatter.last_duplicates = []
|
|
329
|
+
parse_frontmatter.last_unsupported = {}
|
|
308
330
|
data, lines, key, mode = {}, {}, None, None
|
|
309
331
|
scalars = set()
|
|
310
332
|
duplicates = [] # (scope, key) pairs seen more than once
|
|
311
333
|
nested_seen = set()
|
|
334
|
+
blank_lines = 0
|
|
335
|
+
plain_closed = False
|
|
336
|
+
|
|
337
|
+
def plain_part(value):
|
|
338
|
+
# Only plain scalars: quotes and flow values use their existing path.
|
|
339
|
+
# A hash in a URL is data; whitespace followed by # starts a comment.
|
|
340
|
+
m = re.search(r"(?:^|\s)#", value)
|
|
341
|
+
return (value[:m.start()].rstrip(), True) if m else (value, False)
|
|
342
|
+
|
|
343
|
+
def unsupported(reason):
|
|
344
|
+
parse_frontmatter.last_unsupported[key] = reason
|
|
345
|
+
data[key] = None
|
|
346
|
+
scalars.discard(key)
|
|
347
|
+
|
|
312
348
|
for i, raw in enumerate(text.split("\n"), start=2): # +2: the opening '---'
|
|
313
349
|
if not raw.strip():
|
|
350
|
+
if mode == "plain":
|
|
351
|
+
blank_lines += 1
|
|
352
|
+
continue
|
|
353
|
+
if raw.lstrip().startswith("#") and (
|
|
354
|
+
raw[0] not in " \t" or mode in (None, "pending", "plain", "map")):
|
|
355
|
+
if mode == "plain":
|
|
356
|
+
plain_closed = True
|
|
314
357
|
continue
|
|
315
358
|
if raw[0] not in " \t":
|
|
316
359
|
m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", raw)
|
|
@@ -322,10 +365,14 @@ def parse_frontmatter(text):
|
|
|
322
365
|
duplicates.append(("top", key))
|
|
323
366
|
nested_seen = set()
|
|
324
367
|
lines[key] = i
|
|
368
|
+
blank_lines, plain_closed = 0, False
|
|
369
|
+
# Delay the map/scalar decision: `description:` can introduce either.
|
|
370
|
+
if val.startswith("#"):
|
|
371
|
+
val = ""
|
|
325
372
|
if val in (">", "|", ">-", "|-", ">+", "|+"):
|
|
326
373
|
data[key], mode = "", "block"
|
|
327
374
|
elif val == "":
|
|
328
|
-
data[key], mode =
|
|
375
|
+
data[key], mode = None, "pending"
|
|
329
376
|
else:
|
|
330
377
|
# Kept RAW here and finished at the end: a quoted scalar that
|
|
331
378
|
# spans lines carries its closing quote on the last one, so
|
|
@@ -333,10 +380,38 @@ def parse_frontmatter(text):
|
|
|
333
380
|
# buried in the middle of the folded value.
|
|
334
381
|
data[key], mode = val, "scalar"
|
|
335
382
|
scalars.add(key)
|
|
383
|
+
if val[0] not in "\"'[{":
|
|
384
|
+
data[key], plain_closed = plain_part(val)
|
|
385
|
+
mode = "plain"
|
|
386
|
+
elif mode == "pending":
|
|
387
|
+
value = raw.strip()
|
|
388
|
+
if re.match(r"^[A-Za-z0-9_-]+:(?:\s|$)", value):
|
|
389
|
+
data[key], mode = {}, "map"
|
|
390
|
+
m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", value)
|
|
391
|
+
nested_seen.add(m.group(1))
|
|
392
|
+
data[key][m.group(1)] = _typed_scalar(m.group(2).strip())
|
|
393
|
+
elif value[0] in "&*!{[|>" or re.match(r"^[-?:](?:\s|$)", value):
|
|
394
|
+
unsupported("indented value uses a YAML form outside the plain-scalar subset")
|
|
395
|
+
mode = "unsupported"
|
|
396
|
+
else:
|
|
397
|
+
data[key], mode = value, "scalar"
|
|
398
|
+
scalars.add(key)
|
|
399
|
+
if value[0] not in "\"'":
|
|
400
|
+
data[key], plain_closed = plain_part(value)
|
|
401
|
+
mode = "plain"
|
|
336
402
|
elif mode == "block":
|
|
337
403
|
data[key] = (data[key] + " " + raw.strip()).strip()
|
|
338
404
|
elif mode == "scalar":
|
|
339
405
|
data[key] = (data[key] + " " + raw.strip()).strip()
|
|
406
|
+
elif mode == "plain":
|
|
407
|
+
value, closed = plain_part(raw.strip())
|
|
408
|
+
if plain_closed or re.search(r":(?:\s|$)", value):
|
|
409
|
+
unsupported("plain scalar continuation follows a comment or contains a mapping separator")
|
|
410
|
+
mode = "unsupported"
|
|
411
|
+
else:
|
|
412
|
+
separator = "\n" * blank_lines if blank_lines else " "
|
|
413
|
+
data[key] += separator + value
|
|
414
|
+
blank_lines, plain_closed = 0, closed
|
|
340
415
|
elif mode == "map":
|
|
341
416
|
m = re.match(r"^\s+([A-Za-z0-9_-]+):\s*(.*)$", raw)
|
|
342
417
|
if m:
|
|
@@ -385,7 +460,7 @@ def _finish_scalar(v):
|
|
|
385
460
|
if len(v) >= 2 and v[0] == "[" and v[-1] == "]":
|
|
386
461
|
inner = v[1:-1].strip()
|
|
387
462
|
return [] if not inner else [_unquote(p.strip()) for p in inner.split(",")]
|
|
388
|
-
return
|
|
463
|
+
return _typed_scalar(v)
|
|
389
464
|
|
|
390
465
|
|
|
391
466
|
def _unquote(v):
|
|
@@ -416,6 +491,7 @@ def audit(skill_dir, house=False):
|
|
|
416
491
|
"the file, delimited by ---", rel, 1)
|
|
417
492
|
return a
|
|
418
493
|
fm, fm_lines = parse_frontmatter(m.group(1))
|
|
494
|
+
unsupported = dict(getattr(parse_frontmatter, "last_unsupported", {}))
|
|
419
495
|
body = text[m.end():]
|
|
420
496
|
|
|
421
497
|
dups = getattr(parse_frontmatter, "last_duplicates", [])
|
|
@@ -441,12 +517,20 @@ def audit(skill_dir, house=False):
|
|
|
441
517
|
a.ok("FM_YAML_CONFORMANCE", "the subset parse matches the installed "
|
|
442
518
|
"YAML parser", rel)
|
|
443
519
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
520
|
+
for key, reason in unsupported.items():
|
|
521
|
+
a.gap("FM_SUBSET_UNSUPPORTED", "%s: %s; field checks are unmeasured, "
|
|
522
|
+
"not evidence of a missing or invalid value" % (key, reason),
|
|
523
|
+
rel, fm_lines.get(key))
|
|
524
|
+
if "name" not in unsupported:
|
|
525
|
+
_check_name(a, fm, fm_lines, name_on_disk, rel)
|
|
526
|
+
if "description" not in unsupported:
|
|
527
|
+
_check_description(a, fm, fm_lines, rel, house)
|
|
528
|
+
measured = {k: v for k, v in fm.items() if k not in unsupported}
|
|
529
|
+
_check_optional_fields(a, measured, fm_lines, rel)
|
|
447
530
|
_check_keys(a, fm, fm_lines, rel)
|
|
448
531
|
_check_body_budget(a, body, rel, house)
|
|
449
532
|
_check_bundle(a, skill_dir, text, name_on_disk)
|
|
533
|
+
_check_plugin_hooks(a, skill_dir)
|
|
450
534
|
_check_links(a, skill_dir, text, rel)
|
|
451
535
|
_check_distribution(a, skill_dir, name_on_disk)
|
|
452
536
|
_check_prose(a, body, rel)
|
|
@@ -741,6 +825,8 @@ def _check_bundle(a, skill_dir, skill_text, dir_name):
|
|
|
741
825
|
for entry in sorted(os.listdir(d)):
|
|
742
826
|
full = os.path.join(d, entry)
|
|
743
827
|
rel = os.path.join(dir_name, sub, entry)
|
|
828
|
+
if entry in BUNDLE_IGNORE:
|
|
829
|
+
continue
|
|
744
830
|
if os.path.isdir(full):
|
|
745
831
|
a.gap("BUNDLE_NESTED", "%s/%s/ is nested — keep bundled files one level "
|
|
746
832
|
"deep, or the agent previews them instead of reading them"
|
|
@@ -762,6 +848,84 @@ def _check_bundle(a, skill_dir, skill_text, dir_name):
|
|
|
762
848
|
a.ok("BUNDLE_LAYOUT", "bundled directories present and checked")
|
|
763
849
|
|
|
764
850
|
|
|
851
|
+
def _check_plugin_hooks(a, skill_dir):
|
|
852
|
+
"""The plugin's `hooks.json` carries only keys the host evaluates.
|
|
853
|
+
|
|
854
|
+
A skill inside a Claude Code plugin sits at `<plugin>/skills/<name>`, so the hooks
|
|
855
|
+
manifest is two levels up. This runs once per skill, which means a defective manifest
|
|
856
|
+
is reported once per skill of that plugin — a repetition, never a miss.
|
|
857
|
+
|
|
858
|
+
Reported as a GAP rather than a note because the failure is silent in both
|
|
859
|
+
directions: the key does nothing, and nothing said so for six weeks across two
|
|
860
|
+
repositories. `claude plugin validate --strict` accepts the file, so a skill audit is
|
|
861
|
+
the only gate this class can be caught by before a session start.
|
|
862
|
+
"""
|
|
863
|
+
d = os.path.abspath(skill_dir.rstrip("/"))
|
|
864
|
+
parent = os.path.dirname(d)
|
|
865
|
+
if os.path.basename(parent) != "skills":
|
|
866
|
+
a.ok("HOOKS_SCHEMA", "not a plugin skill layout (<plugin>/skills/<name>) — no "
|
|
867
|
+
"hooks manifest to read from here")
|
|
868
|
+
return
|
|
869
|
+
plugin = os.path.dirname(parent)
|
|
870
|
+
path = os.path.join(plugin, "hooks", "hooks.json")
|
|
871
|
+
rel = os.path.join(os.path.basename(plugin), "hooks", "hooks.json")
|
|
872
|
+
if not os.path.isfile(path):
|
|
873
|
+
a.ok("HOOKS_SCHEMA", "the plugin ships no hooks/hooks.json — nothing to read", rel)
|
|
874
|
+
return
|
|
875
|
+
try:
|
|
876
|
+
data = json.load(open(path, encoding="utf-8"))
|
|
877
|
+
except Exception as e:
|
|
878
|
+
a.gap("HOOKS_SCHEMA", "hooks.json does not parse (%s) — Claude Code loads no "
|
|
879
|
+
"hooks at all from a manifest it cannot read" % e, rel)
|
|
880
|
+
return
|
|
881
|
+
events = data.get("hooks")
|
|
882
|
+
if not isinstance(events, dict):
|
|
883
|
+
a.gap("HOOKS_SCHEMA", "hooks.json has no `hooks` object — the manifest declares "
|
|
884
|
+
"nothing the host can run", rel)
|
|
885
|
+
return
|
|
886
|
+
bad = []
|
|
887
|
+
unchecked = set()
|
|
888
|
+
for event, groups in sorted(events.items()):
|
|
889
|
+
if not isinstance(groups, list):
|
|
890
|
+
bad.append("hooks.%s is not a list of matcher groups" % event)
|
|
891
|
+
continue
|
|
892
|
+
for i, group in enumerate(groups):
|
|
893
|
+
if not isinstance(group, dict):
|
|
894
|
+
bad.append("hooks.%s[%d] is not an object" % (event, i))
|
|
895
|
+
continue
|
|
896
|
+
for k in sorted(set(group) - HOOK_GROUP_KEYS):
|
|
897
|
+
bad.append('hooks.%s[%d]: %r sits beside `matcher`, where a matcher group '
|
|
898
|
+
'takes only matcher+hooks — the host ignores it (a filter '
|
|
899
|
+
'belongs inside the handler)' % (event, i, k))
|
|
900
|
+
handlers = group.get("hooks")
|
|
901
|
+
if not isinstance(handlers, list):
|
|
902
|
+
bad.append("hooks.%s[%d].hooks is not a list of handlers" % (event, i))
|
|
903
|
+
continue
|
|
904
|
+
for j, h in enumerate(handlers):
|
|
905
|
+
if not isinstance(h, dict):
|
|
906
|
+
bad.append("hooks.%s[%d].hooks[%d] is not an object" % (event, i, j))
|
|
907
|
+
continue
|
|
908
|
+
if h.get("type") != "command":
|
|
909
|
+
unchecked.add(str(h.get("type")))
|
|
910
|
+
continue
|
|
911
|
+
for k in sorted(set(h) - HOOK_COMMAND_KEYS):
|
|
912
|
+
bad.append("hooks.%s[%d].hooks[%d]: %r is not a key the command-hook "
|
|
913
|
+
"schema knows — the host ignores it" % (event, i, j, k))
|
|
914
|
+
for msg in bad:
|
|
915
|
+
a.gap("HOOKS_SCHEMA", msg, rel)
|
|
916
|
+
if not bad:
|
|
917
|
+
note = ""
|
|
918
|
+
if unchecked:
|
|
919
|
+
# Say what was NOT read. The command handler's key set was measured; the
|
|
920
|
+
# prompt/agent/http/mcp_tool handlers were not, and claiming them clean would
|
|
921
|
+
# be a verdict about something this check never looked at.
|
|
922
|
+
note = " (handler type(s) %s not checked — only the command handler's key "
|
|
923
|
+
note %= ", ".join(sorted(unchecked))
|
|
924
|
+
note += "set is measured here)"
|
|
925
|
+
a.ok("HOOKS_SCHEMA", "every hooks.json key is one Claude Code evaluates%s" % note,
|
|
926
|
+
rel)
|
|
927
|
+
|
|
928
|
+
|
|
765
929
|
def _check_links(a, skill_dir, skill_text, rel):
|
|
766
930
|
"""A relative link that escapes the skill directory arrives broken everywhere.
|
|
767
931
|
|