Skip to content

fix: prune orphaned settings.json hooks on plugin migration#485

Open
code-with-rashid wants to merge 1 commit into
JuliusBrussee:mainfrom
code-with-rashid:fix/orphaned-settings-hooks-on-plugin-migration
Open

fix: prune orphaned settings.json hooks on plugin migration#485
code-with-rashid wants to merge 1 commit into
JuliusBrussee:mainfrom
code-with-rashid:fix/orphaned-settings-hooks-on-plugin-migration

Conversation

@code-with-rashid
Copy link
Copy Markdown

Problem

When migrating from a standalone hook install to the Claude Code plugin, old settings.json entries referencing hook files under ~/.claude/hooks/ are not cleaned up. The plugin system takes over hook delivery, but the manual entries remain pointing at files that may no longer exist. Every subsequent session emits noise like:

Error: Cannot find module '/Users/<me>/.claude/hooks/caveman-activate.js'

Two related root causes:

  1. No cleanup on plugin install β€” removeCavemanHooks() only runs during --uninstall, never during plugin install, so orphaned entries from the previous standalone install persist indefinitely.

  2. rewriteLegacyManagedHookCommands only matched bare node commands β€” the regex ^node\s+... silently skipped hooks written with an absolute node path (e.g. /opt/homebrew/Cellar/node/26.0.0/bin/node), which is the format written by Homebrew, nvm, and the current installer itself. Stale absolute paths from old Node versions were never updated.

Closes #471

Changes

bin/lib/settings.js

  • New extractManagedScriptPath(command) β€” parses any node hook command variant (bare node, absolute path, quoted/unquoted) and returns the script path. Used by both functions below.
  • Rewrote rewriteLegacyManagedHookCommands() β€” now handles all node executable formats, not just bare node. Skips only commands already using the exact target binary; rewrites everything else to the canonical "<absoluteNode>" "<scriptPath>" form.
  • New pruneDeadHookFiles(settings) β€” walks all hook entries, checks fs.existsSync for each managed script path, and removes entries whose files are gone. Only touches basenames in MANAGED_HOOK_BASENAMES; user-authored hooks are always left alone.

bin/install.js

  • installClaude() β€” after plugin install (fresh or already-installed), reads settings.json and calls pruneDeadHookFiles. If orphaned entries are found, writes the cleaned file back and logs how many were removed.
  • installHooks() β€” calls pruneDeadHookFiles before rewriteLegacyManagedHookCommands so dead entries and stale node paths are both resolved before idempotency checks run.

Tests

Added 11 new tests in tests/installer/unit.settings.test.mjs (all 28 unit tests pass):

  • rewriteLegacyManagedHookCommands rewrites stale absolute-node scripts (the old "ignores absolute node" behaviour was the bug)
  • rewriteLegacyManagedHookCommands skips commands already using the target node binary
  • rewriteLegacyManagedHookCommands rewrites unquoted absolute-node scripts
  • pruneDeadHookFiles removes entries whose managed scripts are missing
  • pruneDeadHookFiles keeps entries whose managed scripts exist on disk
  • pruneDeadHookFiles does not touch user-authored hooks even if their files are missing
  • pruneDeadHookFiles handles multiple events and mixed live/dead entries
  • pruneDeadHookFiles handles the bare-node command format
  • pruneDeadHookFiles is a no-op on empty or missing hooks
  • pruneDeadHookFiles preserves non-hook settings keys (theme, statusLine, etc.)

When a user switches from the standalone hook install to the Claude Code
plugin, old settings.json entries referencing hook files under ~/.claude/hooks/
persist after those files are removed. Every subsequent session then emits
"Cannot find module" errors until the user manually edits settings.json.

Two related bugs in `rewriteLegacyManagedHookCommands` made the problem
worse: the function only matched bare `node <script>` patterns, silently
skipping commands written with an absolute node path (common with Homebrew
and nvm installs), so stale absolute paths from old Node versions also went
unrewritten.

Fix:

- Add `extractManagedScriptPath()` helper that parses any node command
  variant (bare, absolute, quoted/unquoted) to extract the script path.

- Rewrite `rewriteLegacyManagedHookCommands()` using the new helper so it
  handles all node-path formats and skips only commands already using the
  target binary β€” not all absolute-path commands.

- Add `pruneDeadHookFiles()` which removes hook entries referencing managed
  scripts that no longer exist on disk. Only touches entries whose script
  basename is in MANAGED_HOOK_BASENAMES β€” never removes user-authored hooks.

- Call `pruneDeadHookFiles()` in `installClaude()` (plugin path) so
  migrating to the plugin cleans up orphaned manual entries automatically.

- Call `pruneDeadHookFiles()` in `installHooks()` before
  `rewriteLegacyManagedHookCommands()` so reinstalls and node-upgrade runs
  also self-heal.

Closes JuliusBrussee#471
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin migration leaves orphaned settings.json hooks β†’ 'Cannot find module caveman-activate.js' (loader:1478) every session

1 participant