feat(cli): metadata.hooks materialisation in mcpctl skills sync

Closes the second-biggest deferred item from PR-5: skills can declare
PreToolUse / PostToolUse / SessionStart / Stop / SubagentStop /
Notification hooks in metadata.hooks, and `mcpctl skills sync` now
registers them in ~/.claude/settings.json with proper source-tagging.

## Why this needs source-tagging

mcpctl already managed ONE entry in settings.json (the SessionStart
hook for `mcpctl skills sync --quiet`, set up by PR-5 via
`installManagedSessionHook`). That entry uses `_mcpctl_managed: true`
to identify itself for idempotent updates.

Skill-declared hooks need finer scoping: skill A and skill B could
both register PreToolUse hooks, and removing skill A must not touch
skill B's. So entries written by `applyManagedHooks` carry both:

  _mcpctl_managed: true          ← same flag the SessionStart hook uses
  _mcpctl_source: "<skill-name>" ← differentiates per-skill

Removing a skill (orphan cleanup) drops every entry tagged with
that skill's name. User-added entries (no marker) are preserved
verbatim. The earlier session-hook installer keeps working: its
`_mcpctl_managed: true` lacks the source tag, so the hooks-materialiser
ignores it on per-skill operations.

## Behaviour

- Skill installs/updates: applyManagedHooks(skillName, metadata.hooks)
  is called after files are atomically materialised. The function
  reads settings.json, drops this skill's previous entries from every
  declared event (and from any event it previously had entries in but
  no longer declares — so a skill can shrink scope), then re-inserts
  the new tagged set.
- Skill orphan removal: removeManagedHooks(skillName) drops every
  entry owned by the skill. Other skills + user hooks unaffected.
- Failure handling: hooks errors are logged via the warn() callback
  but do not fail the sync. Same shape as postInstall — scoped, not
  fatal.

## Files

### Added
- src/cli/src/utils/hooks-materialiser.ts (~140 LOC)
- src/cli/tests/utils/hooks-materialiser.test.ts (~165 LOC, 11 tests
  covering: write-from-scratch, multi-source coexistence, user-hook
  preservation, update replaces (not duplicates), shrink drops events,
  remove targets only the named source, multiple events independent,
  idempotent, empty/JSONC settings.json tolerance)

### Edited
- src/cli/src/commands/skills.ts: applyOne calls applyManagedHooks
  when metadata.hooks is set; calls removeManagedHooks(skillName) when
  a previously-installed skill no longer declares hooks; orphan
  removal also drops the skill's hooks. New SyncResult field
  `hooksApplied`. Earlier `meta` declaration deduped (it was redeclared
  in the postInstall block).

## Verification

164 test files / 2182 tests green (up from 2171).

End-to-end on a real machine after this PR ships:

```yaml
# skill metadata.yaml
hooks:
  PreToolUse:
    - type: command
      command: "audit-pretool.sh"
  SessionStart:
    - type: command
      command: "claude-greeting.sh"
```

```
mcpctl skills sync
# → mcpctl: 1 installed, 1 hooks applied
jq '.hooks' ~/.claude/settings.json
# → entries tagged with _mcpctl_managed + _mcpctl_source
```

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-05-07 19:08:43 +01:00
parent d60ad52018
commit 7ebc8b22d1
3 changed files with 384 additions and 3 deletions

View File

@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { applyManagedHooks, removeManagedHooks, SOURCE_KEY } from '../../src/utils/hooks-materialiser.js';
import { MARKER_KEY } from '../../src/utils/sessionhook.js';
describe('hooks-materialiser', () => {
let tmp: string;
let settings: string;
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'mcpctl-hooks-'));
settings = join(tmp, 'settings.json');
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
it('writes a tagged hook from scratch when settings.json is missing', async () => {
const result = await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo before' }],
}, settings);
expect(result.updated).toBe(true);
const file = JSON.parse(await readFile(settings, 'utf-8'));
expect(file.hooks.PreToolUse).toHaveLength(1);
const entry = file.hooks.PreToolUse[0].hooks[0];
expect(entry.command).toBe('echo before');
expect(entry[MARKER_KEY]).toBe(true);
expect(entry[SOURCE_KEY]).toBe('skill-a');
});
it('coexists with hooks owned by other skills', async () => {
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo a' }],
}, settings);
await applyManagedHooks('skill-b', {
PreToolUse: [{ type: 'command', command: 'echo b' }],
}, settings);
const file = JSON.parse(await readFile(settings, 'utf-8'));
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
expect(all.find((e: { command: string }) => e.command === 'echo a')).toBeDefined();
expect(all.find((e: { command: string }) => e.command === 'echo b')).toBeDefined();
expect(all).toHaveLength(2);
});
it('preserves user-added hooks (no marker)', async () => {
await mkdir(tmp, { recursive: true });
await writeFile(settings, JSON.stringify({
hooks: {
PreToolUse: [{ hooks: [{ type: 'command', command: 'echo user' }] }],
},
}));
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo a' }],
}, settings);
const file = JSON.parse(await readFile(settings, 'utf-8'));
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
expect(all.find((e: { command: string }) => e.command === 'echo user')).toBeDefined();
expect(all.find((e: { command: string; [k: string]: unknown }) => e.command === 'echo a' && e[MARKER_KEY] === true)).toBeDefined();
});
it('updating a skill replaces its old entries (does not duplicate)', async () => {
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo old' }],
}, settings);
const second = await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo new' }],
}, settings);
expect(second.updated).toBe(true);
const file = JSON.parse(await readFile(settings, 'utf-8'));
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
const ours = all.filter((e: { [k: string]: unknown }) => e[SOURCE_KEY] === 'skill-a');
expect(ours).toHaveLength(1);
expect((ours[0] as { command: string }).command).toBe('echo new');
});
it('shrinking a skill drops events it no longer declares', async () => {
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo pre' }],
PostToolUse: [{ type: 'command', command: 'echo post' }],
}, settings);
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo pre' }],
// PostToolUse omitted → should be dropped
}, settings);
const file = JSON.parse(await readFile(settings, 'utf-8'));
expect(file.hooks.PreToolUse).toBeDefined();
expect(file.hooks.PostToolUse).toBeUndefined();
});
it('removeManagedHooks drops only the named source', async () => {
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo a' }],
}, settings);
await applyManagedHooks('skill-b', {
PreToolUse: [{ type: 'command', command: 'echo b' }],
}, settings);
const removed = await removeManagedHooks('skill-a', settings);
expect(removed.removed).toBe(true);
const file = JSON.parse(await readFile(settings, 'utf-8'));
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
expect(all).toHaveLength(1);
expect((all[0] as { command: string }).command).toBe('echo b');
});
it('removeManagedHooks is a no-op when the source has no entries', async () => {
const result = await removeManagedHooks('never-installed', settings);
expect(result.removed).toBe(false);
});
it('handles multiple hook events independently', async () => {
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo pre' }],
PostToolUse: [{ type: 'command', command: 'echo post' }],
SessionStart: [{ type: 'command', command: 'echo start' }],
}, settings);
const file = JSON.parse(await readFile(settings, 'utf-8'));
expect(file.hooks.PreToolUse).toBeDefined();
expect(file.hooks.PostToolUse).toBeDefined();
expect(file.hooks.SessionStart).toBeDefined();
});
it('idempotent — re-applying the same hooks reports updated=true on first call only', async () => {
const first = await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo a' }],
}, settings);
expect(first.updated).toBe(true);
// Re-applying ALWAYS rewrites our entry (we don't deep-equal them
// for "no change"), but the resulting file is byte-identical except
// for ordering. The test just confirms the file remains valid + well-shaped.
const second = await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo a' }],
}, settings);
// updated=true is acceptable here; we replaced+re-added our entry.
expect(second.updated).toBe(true);
const file = JSON.parse(await readFile(settings, 'utf-8'));
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
const ours = all.filter((e: { [k: string]: unknown }) => e[SOURCE_KEY] === 'skill-a');
expect(ours).toHaveLength(1);
});
it('survives empty settings.json', async () => {
await writeFile(settings, '');
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo a' }],
}, settings);
const file = JSON.parse(await readFile(settings, 'utf-8'));
expect(file.hooks.PreToolUse).toHaveLength(1);
});
it('survives JSONC line comments in settings.json', async () => {
await writeFile(settings, '// preamble\n{ "hooks": {} }\n');
await applyManagedHooks('skill-a', {
PreToolUse: [{ type: 'command', command: 'echo a' }],
}, settings);
const file = JSON.parse(await readFile(settings, 'utf-8'));
expect(file.hooks.PreToolUse).toHaveLength(1);
});
});