@ssheleg/make-skill 0.28.0 → 0.29.0

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 CHANGED
@@ -1,3 +1,30 @@
1
+ ## v0.29.0 — the standard now reads a hook key the host ignores
2
+
3
+ Claude Code 2.1.270 began printing `hooks.json: unknown key "if" in
4
+ hooks.PreToolUse[1] ignored` at every session start. The key had been there since
5
+ agent-sync 0.1.0, and the same shape sat in the hooks template `task-pipeline`
6
+ exports into consumer projects — where the documentation gate then ran on **every**
7
+ Bash call rather than on commits. Nothing caught it: not nine member validators, not
8
+ this auditor, and not `claude plugin validate --strict`, which accepts the file.
9
+
10
+ - **`HOOKS_SCHEMA`** reads the plugin's `hooks/hooks.json` from a skill at
11
+ `<plugin>/skills/<name>` and refuses any key outside the set its level accepts —
12
+ a matcher group takes `matcher` + `hooks`; a `command` handler takes `type`,
13
+ `command`, `args`, `if`, `shell`, `timeout`, `statusMessage`, `once`, `async`,
14
+ `asyncRewake` and three `@internal` keys. Both sets are read out of the 2.1.270
15
+ binary's schema rather than from prose. A manifest that does not parse is a GAP
16
+ (the host then loads no hooks at all); a plugin with no manifest passes and says
17
+ so; a `prompt`/`agent`/`http`/`mcp_tool` handler is **declared unchecked**,
18
+ because only the command handler's key set was measured.
19
+ - **`BUNDLE_NESTED` stops reporting `__pycache__`.** A byte-compile cache appears
20
+ the moment a test imports a shipped module; it is gitignored and npm-excluded in
21
+ every member, and three of them were carrying the gap for a directory that never
22
+ ships.
23
+ - `references/host-capabilities.md` states both key sets, where `if` lives, the
24
+ loader's exact warning, and that `validate --strict` is not the gate for this.
25
+
26
+ Every shipped skill in the family passes: 28 of 28 at `0 GAP`, now `19 PASS`.
27
+
1
28
  ## v0.28.0 — the house audit measures the token budget instead of estimating it
2
29
 
3
30
  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.28.0",
3
+ "version": "0.29.0",
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.28.0",
6
+ "version": "0.29.0",
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.28.0"
8
+ version: "0.29.0"
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.
@@ -447,6 +467,7 @@ def audit(skill_dir, house=False):
447
467
  _check_keys(a, fm, fm_lines, rel)
448
468
  _check_body_budget(a, body, rel, house)
449
469
  _check_bundle(a, skill_dir, text, name_on_disk)
470
+ _check_plugin_hooks(a, skill_dir)
450
471
  _check_links(a, skill_dir, text, rel)
451
472
  _check_distribution(a, skill_dir, name_on_disk)
452
473
  _check_prose(a, body, rel)
@@ -741,6 +762,8 @@ def _check_bundle(a, skill_dir, skill_text, dir_name):
741
762
  for entry in sorted(os.listdir(d)):
742
763
  full = os.path.join(d, entry)
743
764
  rel = os.path.join(dir_name, sub, entry)
765
+ if entry in BUNDLE_IGNORE:
766
+ continue
744
767
  if os.path.isdir(full):
745
768
  a.gap("BUNDLE_NESTED", "%s/%s/ is nested — keep bundled files one level "
746
769
  "deep, or the agent previews them instead of reading them"
@@ -762,6 +785,84 @@ def _check_bundle(a, skill_dir, skill_text, dir_name):
762
785
  a.ok("BUNDLE_LAYOUT", "bundled directories present and checked")
763
786
 
764
787
 
788
+ def _check_plugin_hooks(a, skill_dir):
789
+ """The plugin's `hooks.json` carries only keys the host evaluates.
790
+
791
+ A skill inside a Claude Code plugin sits at `<plugin>/skills/<name>`, so the hooks
792
+ manifest is two levels up. This runs once per skill, which means a defective manifest
793
+ is reported once per skill of that plugin — a repetition, never a miss.
794
+
795
+ Reported as a GAP rather than a note because the failure is silent in both
796
+ directions: the key does nothing, and nothing said so for six weeks across two
797
+ repositories. `claude plugin validate --strict` accepts the file, so a skill audit is
798
+ the only gate this class can be caught by before a session start.
799
+ """
800
+ d = os.path.abspath(skill_dir.rstrip("/"))
801
+ parent = os.path.dirname(d)
802
+ if os.path.basename(parent) != "skills":
803
+ a.ok("HOOKS_SCHEMA", "not a plugin skill layout (<plugin>/skills/<name>) — no "
804
+ "hooks manifest to read from here")
805
+ return
806
+ plugin = os.path.dirname(parent)
807
+ path = os.path.join(plugin, "hooks", "hooks.json")
808
+ rel = os.path.join(os.path.basename(plugin), "hooks", "hooks.json")
809
+ if not os.path.isfile(path):
810
+ a.ok("HOOKS_SCHEMA", "the plugin ships no hooks/hooks.json — nothing to read", rel)
811
+ return
812
+ try:
813
+ data = json.load(open(path, encoding="utf-8"))
814
+ except Exception as e:
815
+ a.gap("HOOKS_SCHEMA", "hooks.json does not parse (%s) — Claude Code loads no "
816
+ "hooks at all from a manifest it cannot read" % e, rel)
817
+ return
818
+ events = data.get("hooks")
819
+ if not isinstance(events, dict):
820
+ a.gap("HOOKS_SCHEMA", "hooks.json has no `hooks` object — the manifest declares "
821
+ "nothing the host can run", rel)
822
+ return
823
+ bad = []
824
+ unchecked = set()
825
+ for event, groups in sorted(events.items()):
826
+ if not isinstance(groups, list):
827
+ bad.append("hooks.%s is not a list of matcher groups" % event)
828
+ continue
829
+ for i, group in enumerate(groups):
830
+ if not isinstance(group, dict):
831
+ bad.append("hooks.%s[%d] is not an object" % (event, i))
832
+ continue
833
+ for k in sorted(set(group) - HOOK_GROUP_KEYS):
834
+ bad.append('hooks.%s[%d]: %r sits beside `matcher`, where a matcher group '
835
+ 'takes only matcher+hooks — the host ignores it (a filter '
836
+ 'belongs inside the handler)' % (event, i, k))
837
+ handlers = group.get("hooks")
838
+ if not isinstance(handlers, list):
839
+ bad.append("hooks.%s[%d].hooks is not a list of handlers" % (event, i))
840
+ continue
841
+ for j, h in enumerate(handlers):
842
+ if not isinstance(h, dict):
843
+ bad.append("hooks.%s[%d].hooks[%d] is not an object" % (event, i, j))
844
+ continue
845
+ if h.get("type") != "command":
846
+ unchecked.add(str(h.get("type")))
847
+ continue
848
+ for k in sorted(set(h) - HOOK_COMMAND_KEYS):
849
+ bad.append("hooks.%s[%d].hooks[%d]: %r is not a key the command-hook "
850
+ "schema knows — the host ignores it" % (event, i, j, k))
851
+ for msg in bad:
852
+ a.gap("HOOKS_SCHEMA", msg, rel)
853
+ if not bad:
854
+ note = ""
855
+ if unchecked:
856
+ # Say what was NOT read. The command handler's key set was measured; the
857
+ # prompt/agent/http/mcp_tool handlers were not, and claiming them clean would
858
+ # be a verdict about something this check never looked at.
859
+ note = " (handler type(s) %s not checked — only the command handler's key "
860
+ note %= ", ".join(sorted(unchecked))
861
+ note += "set is measured here)"
862
+ a.ok("HOOKS_SCHEMA", "every hooks.json key is one Claude Code evaluates%s" % note,
863
+ rel)
864
+
865
+
765
866
  def _check_links(a, skill_dir, skill_text, rel):
766
867
  """A relative link that escapes the skill directory arrives broken everywhere.
767
868