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>