Skip to content

Hook executor doesn't set CLAUDE_PLUGIN_ROOT environment variable #24529

Closed

Description

@rexplx

Hook executor doesn't set CLAUDE_PLUGIN_ROOT environment variable

Summary

Plugin hooks that use ${CLAUDE_PLUGIN_ROOT} fail with "Cannot find module" errors because the hook execution system doesn't set this environment variable before running hooks.

Reproduction

  1. Install plugin with hooks using ${CLAUDE_PLUGIN_ROOT} (e.g., claude-mem, claude-reflect)
  2. Enable the plugin: claude plugin enable claude-mem
  3. Execute any tool or submit a prompt
  4. Observe hook errors in logs/TLDR

Expected Behavior

Hook executor should:

  1. Parse ${CLAUDE_PLUGIN_ROOT} placeholder in hook commands
  2. Set environment variable to plugin's cache directory path
  3. Substitute/expand the variable before execution

Similar to how ${CLAUDE_PROJECT_DIR} is handled.

Actual Behavior

  • ${CLAUDE_PLUGIN_ROOT} remains unexpanded
  • Scripts try to execute from /scripts/... (absolute root path)
  • Error: Cannot find module '/scripts/bun-runner.js'

Evidence

Plugin hook definition:

{
  "PostToolUse": [{
    "matcher": "*",
    "hooks": [{
      "type": "command",
      "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" ..."
    }]
  }]
}

Environment check:

$ env | grep CLAUDE
CLAUDE_PROJECT_DIR=/Users/.../project  ✅ SET
CLAUDE_PLUGIN_ROOT=                     ❌ NOT SET

Manual verification:

# Fails without variable
$ node "/scripts/bun-runner.js"
Error: Cannot find module '/scripts/bun-runner.js'

# Works with variable set
$ export CLAUDE_PLUGIN_ROOT="$HOME/.claude/plugins/cache/thedotmack/claude-mem/9.1.1"
$ node "${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js"
✅ Success

Affected Plugins

  • claude-mem@thedotmack (v9.1.1)
  • claude-reflect@claude-reflect-marketplace (v2.5.1)
  • Any plugin using ${CLAUDE_PLUGIN_ROOT} in hooks

Impact

  • ❌ PostToolUse observation tracking broken
  • ❌ UserPromptSubmit hooks broken
  • ❌ SessionStart hooks broken
  • ⚠️ Hook errors spam logs but don't break core functionality
  • ⚠️ MCP tools from plugins still work (different mechanism)

Environment

  • OS: macOS (Darwin 25.3.0)
  • Claude Code: Latest as of 2026-02-09
  • Plugins installed: claude-mem 9.1.1, claude-reflect 2.5.1

Additional Context

This appears to be a regression or missing feature in the hook execution system. Other environment variables like ${CLAUDE_PROJECT_DIR} work correctly, suggesting the infrastructure exists but ${CLAUDE_PLUGIN_ROOT} isn't included.

Activity

  1. AKhozya commented on Feb 27, 2026

    @AKhozya

    Still hitting this on every plugin update. Current workaround is a post-update sed:

    PLUGIN_ROOT="$HOME/.claude/plugins/cache/<marketplace>/<plugin>/<version>"
    sed -i '' "s|\${CLAUDE_PLUGIN_ROOT}|${PLUGIN_ROOT}|g" "${PLUGIN_ROOT}/hooks/hooks.json"

    Affects every plugin that uses ${CLAUDE_PLUGIN_ROOT} in hooks — including everything-claude-code (10 references in hooks.json as of v1.6.0). The variable works for MCP tool paths but not for hook commands.

    Would be great to get this fixed — it's the #1 source of post-update maintenance friction for plugin users.

    Environment: macOS (Darwin 25.3.0), Claude Code latest, everything-claude-code 1.6.0

  2. AKhozya commented on Mar 2, 2026

    @AKhozya

    I dug into this further using the code from PR #30024, which reveals the actual hook executor path in the compiled CLI. There are two bugs, not one:

    Bug 1: Plugin context lost during hook merge. The hook executor does template substitution conditionally:

    if ($) X = X.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, $);

    But $ (the plugin root path) is undefined when plugin hooks from hooks.json are merged with user hooks from settings.json. The plugin context (which plugin a hook belongs to) is lost during the merge, so $ is never set. The command runs with the literal ${CLAUDE_PLUGIN_ROOT} string, which the shell resolves to empty.

    Bug 2: Env var never set in spawned process. Even if template substitution worked, CLAUDE_PLUGIN_ROOT is never added to the spawned process's environment. This breaks scripts that read it at runtime — e.g., hookify's os.environ.get('CLAUDE_PLUGIN_ROOT') for Python imports.

    Why existing PRs don't fix this

    Suggested fix

    Two changes in the hook executor:

    1. Tag hooks with their plugin origin during hooks.json loading, so the plugin root path survives the merge with user hooks:
    // When loading plugin hooks.json
    for (const config of hookConfigs) {
      config._pluginRoot = pluginDir;
    }
    1. Use the tagged path for both template substitution AND process environment:
    // When executing a hook command
    const pluginRoot = hook._pluginRoot;
    if (pluginRoot) {
      command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot);
      env.CLAUDE_PLUGIN_ROOT = pluginRoot;
    }

    This matches how CLAUDE_PROJECT_DIR works (always available) and how MCP server configs handle ${CLAUDE_PLUGIN_ROOT} (plugin context preserved). It would fix all affected plugins at once instead of requiring per-plugin workarounds.

    Impact

    Every official plugin with hooks uses ${CLAUDE_PLUGIN_ROOT}: hookify (4 hooks), ralph-wiggum (1), explanatory-output-style (1), learning-output-style (1), security-guidance (1). Plus third-party plugins (everything-claude-code, claude-mem, claude-reflect). Current workaround is sed -i to replace with absolute paths after every plugin update.

  3. traylorre commented on Mar 14, 2026

    @traylorre

    Confirming on v2.1.75 (Linux/WSL2). Consistent with @AKhozya's root cause.

    Additional finding: plugin hook errors are silent

    Expected: Plugin hooks that fail should surface an error in the UI, the same way user-defined hooks do.

    Actual: Plugin hooks that fail due to unresolved ${CLAUDE_PLUGIN_ROOT} produce no visible error. Probe testing confirms hooks are dispatched for all event types (SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Stop). They just fail silently.

    The error rendering path itself works. A user-defined hook that exits non-zero:

    {"matcher": "Bash", "hooks": [{"type": "command", "command": "exit 1"}]}

    immediately shows PreToolUse:Bash hook error. The difference is specific to plugin-origin hooks.

    Affects: All five @claude-plugins-official plugins with hooks: learning-output-style, explanatory-output-style, hookify, security-guidance, ralph-loop.

    Workaround

    Extended @AKhozya's sed to also set the env var for scripts that read it at runtime, and to patch both cache/ and marketplaces/: https://gist.github.com/traylorre/0cc71f26704878751b6185f473304f54

  4. alessandropcostabr commented on Mar 15, 2026

    @alessandropcostabr

    Confirmed on Linux — Claude Code v2.1.76 (Opus 4.6)

    Platform: Ubuntu Linux 6.17.0-14-generic
    Claude Code: v2.1.76

    Reproduction

    On Linux, CLAUDE_PLUGIN_ROOT is empty/unset for all hook types (SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit). Hook commands containing ${CLAUDE_PLUGIN_ROOT} expand to absolute root paths like /hooks/run-hook.cmd → exit 127.

    Affected official plugins (from claude-plugins-official)

    Plugin Hook type Command that fails
    superpowers SessionStart "${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd" session-start
    security-guidance PreToolUse python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py
    ralph-loop Stop ${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh
    hookify PreToolUse, PostToolUse, Stop, UserPromptSubmit python3 ${CLAUDE_PLUGIN_ROOT}/hooks/*.py
    learning-output-style SessionStart ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh
    explanatory-output-style SessionStart ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/session-start.sh
    semgrep SessionStart ${CLAUDE_PLUGIN_ROOT}/scripts/check_version.sh

    All 7 plugins silently fail on Linux. The user sees SessionStart:startup hook error on every session start.

    Workaround

    The claude-mem plugin (by @thedotmack) already implements a shell-level fallback in all its hooks. This pattern resolves the path correctly on Linux regardless of whether CLAUDE_PLUGIN_ROOT is set:

    {
      "type": "command",
      "command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/cache/<plugin-path>\"; \"$_R/hooks/my-hook.sh\""
    }

    We applied this pattern to all 7 affected plugins locally (14 hooks.json files patched across cache + marketplace directories) and the errors are resolved. All plugins function correctly after the fallback resolves the path.

    Suggested fix

    Either:

    1. Fix Claude Code to properly set CLAUDE_PLUGIN_ROOT on Linux (ideal)
    2. Or update the official plugins to include the fallback pattern (defensive, works regardless of Claude Code version)

    Option 1 is the root fix. Option 2 is what claude-mem already does and is a good defensive practice for any plugin.


    Investigation and workaround implemented with Claude Code (Opus 4.6). Co-authored by @alessandropcostabr.

  5. AKhozya commented on Mar 21, 2026

    @AKhozya

    Any update on it, seems quite bad and affects quite a few people

  6. alessandropcostabr commented on Mar 27, 2026

    @alessandropcostabr

    New finding: hooks.json is reset on every session start, invalidating all community workarounds

    Platform: Ubuntu Linux 6.17.0-19-generic
    Claude Code: v2.1.76

    Discovery

    While investigating the ralph-loop stop hook Permission denied error (#38686), we discovered that Claude Code overwrites plugin hooks.json files on every new session, not just on claude plugins update.

    Evidence

    1. Patched ralph-loop/hooks/hooks.json to use bash prefix:
    "command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"...\"; bash \"$_R/hooks/stop-hook.sh\""
    1. Restarted Claude Code session (ctrl+c → reopen)

    2. Observed the file was reverted to the original unpatched command:

    "command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"

    Our _R fallback, bash prefix — everything was stripped. Claude Code regenerated the hooks.json from its internal plugin definition.

    Impact on community workarounds

    Every workaround shared in this thread assumes patches survive between sessions:

    • @AKhozya's sed post-update script → reverted on next session
    • @traylorre's extended gist → reverted on next session
    • Our Python patching script (fix-plugin-root-fallbacks.sh) → reverted on next session

    This means users must re-run their workaround scripts before every single Claude Code session, not just after claude plugins update. In practice, this makes all file-based workarounds unreliable.

    enabledPlugins also ineffective

    We also confirmed that setting enabledPlugins to false does NOT prevent hook execution (bug #27521 still active):

    "ralph-loop@claude-plugins-official": false

    Result: the stop hook still fires and still produces the Permission denied error. The only way to stop it was claude plugins uninstall ralph-loop@claude-plugins-official.

    Summary of what DOESN'T work

    Workaround Survives session restart? Stops hook execution?
    Patch hooks.json (sed/python) ❌ Reverted by Claude Code N/A
    chmod +x on .sh files ✅ Persists ❌ Permission denied persists
    enabledPlugins: false ✅ Persists ❌ Hook still fires (#27521)
    claude plugins uninstall ✅ Persists ✅ Only reliable solution

    What this means for the fix

    The only viable path is fixing this in Claude Code itself. No user-side workaround is durable. Specifically:

    1. Set CLAUDE_PLUGIN_ROOT in the spawned process environment (the original bug)
    2. Respect enabledPlugins when loading hooks ([BUG] Marketplace plugins with hooks cause "No such file or directory" errors for non-installed plugins #27521)
    3. Use bash /path/to/script.sh instead of direct exec (fixes the sandbox Permission denied issue on Linux)

    Without at least one of these, users on Linux cannot use any plugin with .sh hooks.


    Investigated with Claude Code (Opus 4.6). Co-authored by @alessandropcostabr.

  7. yurukusa commented on Mar 29, 2026

    @yurukusa

    Workaround: set the variable yourself in the hook:

    if [ -z "$CLAUDE_PLUGIN_ROOT" ]; then
        for dir in "$HOME/.claude/plugins" ".claude/plugins" "$HOME/.claude/marketplace"; do
            if [ -d "$dir" ]; then
                export CLAUDE_PLUGIN_ROOT="$dir"
                break
            fi
        done
    fi
    exit 0

    This ensures CLAUDE_PLUGIN_ROOT is available regardless of whether the executor sets it.

  8. alessandropcostabr commented on Mar 29, 2026

    @alessandropcostabr

    @yurukusa The workaround above has a few issues:

    1. Path is wrong — CLAUDE_PLUGIN_ROOT must be per-plugin, per-version, not a generic directory. Each plugin resolves to ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>. A generic fallback like $HOME/.claude/plugins won't find the hook scripts.

    2. exit 0 kills the hook — The workaround sets the variable but then exits before executing the actual hook command.

    3. Overwritten on every session — As I documented in my comment from March 27, Claude Code resets hooks.json on every session start, not just on claude plugins update. Any manual edit is lost immediately.


    What actually works

    We maintain a script (fix-plugin-root-fallbacks.sh) that uses the _R= fallback pattern per-plugin:

    # Before (broken):
    "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start"
    
    # After (works):
    "command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"/home/user/.claude/plugins/cache/superpowers/5.0.6\"; \"$_R/hooks/run-hook.cmd\" session-start"

    This pattern:

    • Tries CLAUDE_PLUGIN_ROOT first (for when Anthropic eventually fixes it)
    • Falls back to the exact versioned path for each plugin
    • Doesn't break the actual hook command execution
    • Handles cache/ and marketplace/ directories separately

    The script also fixes two related issues:

    Must be re-run after every claude plugins update (or automated via a post-update hook).


    Related: claude-mem plugin as a case study

    We maintain the claude-mem plugin (persistent cross-session memory via semantic search). During refactoring, we fixed 3 upstream bugs in the plugin code and rebuilt it with the _R= fallback pattern baked into hooks.json. This plugin uses UserPromptSubmit, PostToolUse, SessionStart, and Stop hooks — all affected by this bug.

    After 20 days of production use (3,100+ observations, 730+ session summaries), the _R= fallback pattern is confirmed reliable. The plugin has zero hook failures since the fix.

    The real fix needs to come from Anthropic — either:

    1. Set CLAUDE_PLUGIN_ROOT correctly in the hook executor
    2. Or stop resetting hooks.json on every session start so community patches persist
  9. claude commented on Apr 17, 2026

    @claude
    Contributor

    This issue was fixed as of version 2.1.94.

  10. github-actions commented on Apr 25, 2026

    @github-actions

    This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.

  11. locked as resolved and limited conversation to collaborators on Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:corearea:toolsbugSomething isn't workinghas reproHas detailed reproduction stepsplatform:macosIssue specifically occurs on macOSstaleIssue is inactive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions