From 952d54362fc3018409326a9e7e55979129c36172 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Fri, 31 Jul 2026 08:39:59 -0400 Subject: [PATCH 1/2] Ship `hooks/`, and check that a description names the installed command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hooks/` was absent from the bundle list, so a skill shipping a hook delivered everything except the hook. This is the fourth instance of one root cause — a directory that exists in source and is not in `copy_bundle_dirs` — after `pr-validate`'s hooks, domain `knowledge/`, and `workflows/`. The `BUNDLE_DIRS` drift test covers it now, so a fifth cannot land silently. Separately, the installer prefixes every emitted skill, so a description advertising `/` names a command no operator exposes. The description is the discovery surface, which makes a wrong trigger string a selection failure rather than a typo. The check requires the `mms-` form. Its first version flagged `@metamask/gator-cli` — a scoped package, not a slash command. A negative lookbehind now excludes `@scope/name` and path-like forms, and that case is a test rather than a note. --- .github/scripts/lint-skill-entry.mjs | 16 ++++++++++++++++ test/lint-skill-entry.test.mjs | 23 +++++++++++++++++++++++ tools/install | 2 +- tools/skill-schema.mjs | 2 +- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/scripts/lint-skill-entry.mjs b/.github/scripts/lint-skill-entry.mjs index 8de11ba..c737a0c 100644 --- a/.github/scripts/lint-skill-entry.mjs +++ b/.github/scripts/lint-skill-entry.mjs @@ -111,6 +111,22 @@ export function lintSkill(skill) { } } + // The installer prefixes every emitted skill, so a description advertising `/` + // names a command no operator exposes. The description IS the discovery surface, so a + // wrong trigger string is a selection failure, not a typo. + // + // The lookbehind keeps a scoped package (`@metamask/gator-cli`) or a path + // (`skills/gator-cli`) from being read as a slash command. + if (raw.description && raw.name) { + const bare = new RegExp(`(? { assert.match(output, /over the \d+-char budget/u); }); }); + +describe('description names the installed command', () => { + test('a bare / trigger fails', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'demo', 'name: demo\ndescription: Triggers on /demo when asked.'); + const { code, output } = lint(root); + assert.equal(code, 1, output); + assert.match(output, /advertises `\/demo` but the installer emits `mms-demo`/u); + }); + + test('the prefixed form passes', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'demo', 'name: demo\ndescription: Triggers on /mms-demo when asked.'); + assert.equal(lint(root).code, 0); + }); + + test('a scoped package name is not a slash command', () => { + // `@metamask/gator-cli` tripped the first version of this rule. + const root = makeRoot(); + writeSkill(root, 'web3-tools', 'gator-cli', 'name: gator-cli\ndescription: Operate the @metamask/gator-cli package.'); + assert.equal(lint(root).code, 0); + }); +}); diff --git a/tools/install b/tools/install index af56d28..2df02e4 100755 --- a/tools/install +++ b/tools/install @@ -346,7 +346,7 @@ write_user_codex() { copy_bundle_dirs() { local skill_dir="$1" dest_dir="$2" label="$3" local bundle - for bundle in references scripts assets adapters workflows; do + for bundle in references scripts assets adapters workflows hooks; do if [[ -d "$skill_dir/$bundle" ]]; then action "$label/$bundle/" $DRY_RUN && continue diff --git a/tools/skill-schema.mjs b/tools/skill-schema.mjs index 290b422..2d47184 100644 --- a/tools/skill-schema.mjs +++ b/tools/skill-schema.mjs @@ -14,7 +14,7 @@ export const MATURITY_VALUES = ['experimental', 'stable', 'deprecated']; export const SCOPE_VALUES = ['user', 'project']; // Directories the installer copies alongside skill.md (see tools/install). -export const BUNDLE_DIRS = ['references', 'scripts', 'assets', 'adapters', 'workflows']; +export const BUNDLE_DIRS = ['references', 'scripts', 'assets', 'adapters', 'workflows', 'hooks']; // Directories allowed beside skill.md: the bundle dirs plus the repo-overlay // dir. Anything else is rejected, because the installer does not ship it and any From c57959197f6759524557ce1056aa056a24412ed9 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Sat, 1 Aug 2026 00:59:28 +0900 Subject: [PATCH 2/2] feat(cli): resolve hook registration at install, and on demand (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #99, which is what makes `hooks/` reach a consumer at all. **Base is #99's branch, not `main`** — review that one first. ## The gap Copying a hook does not activate it. Claude Code runs one only once it is registered in `settings.json`, and the path to register is **absolute** — different per machine, per consumer repo, and per `mms-` prefixed skill directory. So the setup reference could only ever say: ``` python3 /absolute/path/to/evidence/hooks/pr-evidence-gate.py ``` and leave the reader to work out what that is. Meanwhile `evidence` cites the hook twice in its body as its enforcement mechanism, so someone installing it reasonably assumes the gate is live. It isn't. ## Two surfaces, one output **At install** — when any installed skill ships a hook, `tools/install` prints the registration with every path resolved against the actual install: ``` Note: 1 skill(s) ship a hook. Copying the file does not activate it — Claude Code runs a hook only once it is registered in settings.json. Add this to ~/.claude/settings.json (or /.claude/settings.json): { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3 /.claude/skills/mms-gatekeeper/hooks/evidence-gate.py" } ] } ] } } ``` **On demand** — `metamask-skills hooks [--target ]` prints the same thing, for anyone who scrolled past it or is re-registering later. Says *"No installed skill ships a hook"* rather than printing empty JSON, and exits non-zero only when the target has no installed skills at all. ## What it deliberately does not do **Neither surface writes to `settings.json`.** Editing a user's operator config is a materially larger permission than "copy files into the repo you pointed me at", and it should be decided deliberately rather than inherited as a side effect of shipping one hook. This is also the only hook in the corpus — a sample of one is thin evidence for automating a write to `$HOME`. ## Test plan - [x] `yarn test` — 69 pass / 0 fail across three files - [x] Fixture skill with `hooks/evidence-gate.py`: file delivered, registration printed, path resolved - [x] Printed registration **parses as JSON** — asserted by `JSON.parse`, matcher and command checked - [x] `metamask-skills hooks` emits the same registration - [x] Target with skills but no hooks → message, exit 0 - [x] Target with no installed skills → warning, exit 1 ## Note An earlier version emitted a trailing comma and told the reader to delete it. Handing someone JSON that doesn't parse is worse than handing them none, so the entries are joined properly and the output is valid as printed. --- bin/metamask-skills.mjs | 52 ++++++++++++++++++++++++++++++ test/cli.test.mjs | 70 +++++++++++++++++++++++++++++++++++++++++ tools/install | 45 ++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/bin/metamask-skills.mjs b/bin/metamask-skills.mjs index af1ec09..ea4cb0a 100755 --- a/bin/metamask-skills.mjs +++ b/bin/metamask-skills.mjs @@ -22,6 +22,7 @@ Usage: metamask-skills describe [options] metamask-skills sync [options] metamask-skills postinstall [options] + metamask-skills hooks [options] metamask-skills install [options] Options: @@ -799,6 +800,55 @@ function invokedDirectly() { } } + +/** + * Print the Claude Code registration for every hook an installed skill ships. + * + * The installer copies `hooks/` like any other bundle directory, but a hook does nothing + * until it is registered in settings.json — and the path to register is absolute, so it + * differs per machine and per consumer repo and cannot be documented as a constant. This + * resolves it against the actual install. + */ +function printHookRegistration(args) { + const { target } = parseGlobalArgs(args); + const skillsDir = path.join(target, '.claude', 'skills'); + + let entries = []; + try { + for (const skill of readdirSync(skillsDir, { withFileTypes: true })) { + if (!skill.isDirectory()) continue; + const hooks = path.join(skillsDir, skill.name, 'hooks'); + if (!dirExists(hooks)) continue; + for (const file of readdirSync(hooks)) { + if (file.endsWith('.py')) entries.push(path.join(hooks, file)); + } + } + } catch { + warn(`no installed skills found under ${skillsDir}`); + return 1; + } + + if (entries.length === 0) { + process.stdout.write('No installed skill ships a hook.\n'); + return 0; + } + + const commands = entries + .map((f) => ` { "type": "command", "command": "python3 ${f}" }`) + .join(',\n'); + + process.stdout.write( + `${entries.length} hook(s) installed. Copying a hook does not activate it — Claude Code\n` + + `runs one only once it is registered. Add this to ~/.claude/settings.json, or to\n` + + `${path.join(target, '.claude', 'settings.json')} to scope it to this repo:\n\n` + + ' {\n "hooks": {\n "PreToolUse": [\n {\n "matcher": "Bash",\n "hooks": [\n' + + `${commands}\n` + + ' ]\n }\n ]\n }\n }\n', + ); + return 0; +} + + if (invokedDirectly()) { const [command, ...args] = process.argv.slice(2); if (!command || command === '-h' || command === '--help') { @@ -817,6 +867,8 @@ if (invokedDirectly()) { exitCode = sync(args); } else if (command === 'postinstall') { exitCode = postinstall(args); + } else if (command === 'hooks') { + exitCode = printHookRegistration(args); } else if (command === 'install') { exitCode = install(args); } else { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index e4829a1..d14fbf4 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -240,3 +240,73 @@ describe('managed skill pruning', () => { assert.equal(existsSync(stale), true); }); }); + +describe('hook registration', () => { + // Copying a hook does not activate it: Claude Code runs one only once it is registered + // in settings.json, and the path to register is absolute — different per machine and per + // consumer repo, so it cannot be documented as a constant. Both surfaces resolve it. + let root; + let source; + let target; + + before(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'mms-hooks-')); + source = path.join(root, 'source'); + target = path.join(root, 'target'); + const dir = path.join(source, 'domains', 'testing', 'skills', 'gatekeeper'); + mkdirSync(path.join(dir, 'hooks'), { recursive: true }); + mkdirSync(path.join(source, 'tools'), { recursive: true }); + symlinkSync(INSTALL, path.join(source, 'tools', 'install')); + mkdirSync(target, { recursive: true }); + writeFileSync( + path.join(dir, 'skill.md'), + ['---', 'name: gatekeeper', 'description: Gate writes', 'maturity: stable', '---', 'Body.'].join('\n'), + ); + writeFileSync(path.join(dir, 'hooks', 'evidence-gate.py'), 'print("gate")\n'); + const r = spawnSync('bash', [INSTALL, '--target', target, '--repo', 'core', '--source', source], { + encoding: 'utf8', + }); + assert.equal(r.status, 0, r.stderr); + installOutput = r.stdout; + }); + + let installOutput = ''; + + after(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test('the hook file is delivered', () => { + assert.ok( + existsSync(path.join(target, '.claude/skills', 'mms-gatekeeper', 'hooks', 'evidence-gate.py')), + ); + }); + + test('install prints a registration with the path resolved', () => { + assert.match(installOutput, /Copying the file does not activate it/u); + assert.match(installOutput, /mms-gatekeeper\/hooks\/evidence-gate\.py/u); + }); + + test('the printed registration is valid JSON', () => { + const body = installOutput.slice(installOutput.indexOf('{'), installOutput.lastIndexOf('}') + 1); + const parsed = JSON.parse(body); + assert.equal(parsed.hooks.PreToolUse[0].matcher, 'Bash'); + assert.match(parsed.hooks.PreToolUse[0].hooks[0].command, /^python3 \//u); + }); + + test('the hooks subcommand prints the same registration on demand', () => { + const r = spawnSync(process.execPath, [BIN, 'hooks', '--target', target], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const body = r.stdout.slice(r.stdout.indexOf('{'), r.stdout.lastIndexOf('}') + 1); + assert.equal(JSON.parse(body).hooks.PreToolUse[0].hooks.length, 1); + }); + + test('a target with no hooks says so rather than printing empty JSON', () => { + const bare = mkdtempSync(path.join(os.tmpdir(), 'mms-nohooks-')); + mkdirSync(path.join(bare, '.claude', 'skills', 'mms-x'), { recursive: true }); + const r = spawnSync(process.execPath, [BIN, 'hooks', '--target', bare], { encoding: 'utf8' }); + assert.equal(r.status, 0); + assert.match(r.stdout, /No installed skill ships a hook/u); + rmSync(bare, { recursive: true, force: true }); + }); +}); diff --git a/tools/install b/tools/install index 2df02e4..816c4df 100755 --- a/tools/install +++ b/tools/install @@ -386,6 +386,9 @@ copy_domain_knowledge() { copy_project_bundles() { local skill_dir="$1" out_name="$2" + # A hook is inert until an operator registers it, so remember which skills shipped one + # and print the registration at the end rather than leaving the file to be discovered. + [[ -d "$skill_dir/hooks" ]] && HOOK_SKILLS+=("$out_name") copy_bundle_dirs "$skill_dir" "$CLAUDE_DIR/$out_name" ".claude/skills/$out_name" copy_domain_knowledge "$skill_dir" "$CLAUDE_DIR/$out_name" ".claude/skills/$out_name" copy_bundle_dirs "$skill_dir" "$CURSOR_DIR/$out_name" ".cursor/rules/$out_name" @@ -592,6 +595,7 @@ RESOLVED_KEYS=() RESOLVED_DIRS=() RESOLVED_DOMAINS=() SKIPPED_USER_SKILLS=() +HOOK_SKILLS=() FAILED_SKILLS=() EXPECTED_PROJECT_SKILLS=() @@ -660,6 +664,47 @@ $PRUNE_STALE && remove_stale_project_skills echo $DRY_RUN && echo "Dry run complete. No files written." || echo "Install complete." +set +u +HOOK_COUNT=${#HOOK_SKILLS[@]} +set -u +if (( HOOK_COUNT > 0 )) && ! $DRY_RUN; then + echo + echo "Note: $HOOK_COUNT skill(s) ship a hook. Copying the file does not activate it —" + echo "Claude Code runs a hook only once it is registered in settings.json." + echo + echo "Add this to ~/.claude/settings.json (or $TARGET/.claude/settings.json):" + echo + + hook_entries=() + for out_name in "${HOOK_SKILLS[@]}"; do + for hook_file in "$CLAUDE_DIR/$out_name/hooks"/*.py; do + [[ -f "$hook_file" ]] || continue + hook_entries+=(" { \"type\": \"command\", \"command\": \"python3 $hook_file\" }") + done + done + + echo ' {' + echo ' "hooks": {' + echo ' "PreToolUse": [' + echo ' {' + echo ' "matcher": "Bash",' + echo ' "hooks": [' + for i in "${!hook_entries[@]}"; do + if (( i < ${#hook_entries[@]} - 1 )); then + echo "${hook_entries[$i]}," + else + echo "${hook_entries[$i]}" + fi + done + echo ' ]' + echo ' }' + echo ' ]' + echo ' }' + echo ' }' + echo + echo "Re-run \`metamask-skills hooks\` to print this again." +fi + set +u SKIPPED_USER_COUNT=${#SKIPPED_USER_SKILLS[@]} set -u