Files
mcpctl/src/cli/tests/utils
Michal 7ebc8b22d1 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>
2026-05-07 19:08:43 +01:00
..