Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/scripts/lint-skill-entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,22 @@ export function lintSkill(skill) {
}
}

// The installer prefixes every emitted skill, so a description advertising `/<name>`
// 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(`(?<![\\w@/-])/${raw.name}\\b`, 'u');
const prefixed = new RegExp(`(?<![\\w@/-])/${INSTALLED_PREFIX}${raw.name}\\b`, 'u');
if (bare.test(raw.description) && !prefixed.test(raw.description)) {
errors.push(
`\`description\` advertises \`/${raw.name}\` but the installer emits \`${INSTALLED_PREFIX}${raw.name}\`; name the installed form`,
);
}
}

for (const section of RECOMMENDED_SECTIONS) {
// A trailing `\b` let `## When To Use Cases` satisfy `When To Use` — a different
// section. Anchoring to end-of-line fixes that but rejects `## Workflows` and
Expand Down
52 changes: 52 additions & 0 deletions bin/metamask-skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Usage:
metamask-skills describe <skill|domain/skill> [options]
metamask-skills sync [options]
metamask-skills postinstall [options]
metamask-skills hooks [options]
metamask-skills install [options]

Options:
Expand Down Expand Up @@ -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') {
Expand All @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
23 changes: 23 additions & 0 deletions test/lint-skill-entry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<name> 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);
});
});
47 changes: 46 additions & 1 deletion tools/install
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -592,6 +595,7 @@ RESOLVED_KEYS=()
RESOLVED_DIRS=()
RESOLVED_DOMAINS=()
SKIPPED_USER_SKILLS=()
HOOK_SKILLS=()
FAILED_SKILLS=()
EXPECTED_PROJECT_SKILLS=()

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tools/skill-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down