diff --git a/.github/scripts/lint-skill-entry.mjs b/.github/scripts/lint-skill-entry.mjs index 8de11ba1..c737a0ce 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(`(? [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 e4829a10..d14fbf43 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/test/lint-skill-entry.test.mjs b/test/lint-skill-entry.test.mjs index 744096ce..947021af 100644 --- a/test/lint-skill-entry.test.mjs +++ b/test/lint-skill-entry.test.mjs @@ -268,3 +268,26 @@ describe('changed-files mode', () => { 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 af56d281..816c4dfa 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 @@ -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 diff --git a/tools/skill-schema.mjs b/tools/skill-schema.mjs index 290b4222..2d471844 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