CLAUDE_PLUGIN_ROOT inconsistent between hooks/skills and agent environment for local marketplace plugins

Status Fixed / completed
Reported on v2.1.83
Maintainer reply None cached
Activity 6 comments · opened Mar 25, 2026 · closed Apr 8, 2026

Bug Description

When a plugin is installed from a local marketplace (claude plugin install cat@cat), CLAUDE_PLUGIN_ROOT is set to two different values depending on the context:

| Context | CLAUDE_PLUGIN_ROOT |
|---|---|
| Hooks (e.g., session-start.sh) | /workspace/plugin (source directory) |
| Skill preprocessor directives (${CLAUDE_PLUGIN_ROOT}) | /workspace/plugin (source directory) |
| Agent Bash environment (echo $CLAUDE_PLUGIN_ROOT) | ~/.config/claude/plugins/cache/cat/cat/2.1 (cache/install directory) |

Expected Behavior

CLAUDE_PLUGIN_ROOT should consistently point to the installation directory (the installPath from installed_plugins.json) in all contexts — hooks, skill preprocessor directives, and the agent's shell environment.

Actual Behavior

Hooks and skill preprocessor directives receive the source directory from the marketplace definition, while the agent's Bash environment receives the cache/install directory. This causes skills that reference ${CLAUDE_PLUGIN_ROOT}/client/bin/... to fail with "no such file or directory" because compiled binaries exist in the cache but not in the source tree.

Steps to Reproduce

  1. Create a local marketplace with source pointing to a plugin directory:

``json
// .claude-plugin/marketplace.json
{
"name": "my-marketplace",
"owner": { "name": "example" },
"plugins": [
{
"name": "my-plugin",
"source": "./plugin",
"description": "Example plugin"
}
]
}
``

  1. Install the plugin: claude plugin install my-marketplace@my-plugin
  2. Verify installed_plugins.json shows installPath pointing to the cache directory
  3. In a hook script, log $CLAUDE_PLUGIN_ROOT — it shows the source directory (e.g., /workspace/plugin)
  4. In the agent's Bash tool, run echo $CLAUDE_PLUGIN_ROOT — it shows the cache directory

Evidence

Debug output from a session-start hook:

script_dir=/workspace/plugin/hooks
BASH_SOURCE[0]=/workspace/plugin/hooks/session-start.sh
CLAUDE_PLUGIN_ROOT (raw)=/workspace/plugin

installed_plugins.json entry:

{
  "installPath": "/home/node/.config/claude/plugins/cache/cat/cat/2.1",
  "version": "2.1"
}

Agent Bash environment:

CLAUDE_PLUGIN_ROOT=/home/node/.config/claude/plugins/cache/cat/cat/2.1

Impact

Any skill using ${CLAUDE_PLUGIN_ROOT} in preprocessor directives to locate compiled binaries or other non-source artifacts fails when the plugin is installed from a local marketplace, because the preprocessor resolves the variable to the source directory where those artifacts don't exist.

Environment

  • Claude Code CLI (Version 2.1.83)
  • Linux (WSL2)

This is a regression. Version 2.1.81 works as expected.

View original on GitHub ↗

6 Comments

cowwoc · 6 months ago

This issue is still reproducible in version 2.1.85

cowwoc · 6 months ago

Root Cause Analysis

The regression was introduced in v2.1.83. The corresponding v2.1.83 changelog entry is:

Improved plugin startup — commands, skills, and agents now load from disk cache without re-fetching

This added a new "cache-only" session-start loader function (minified as Sc_ in v2.1.83) that doesn't exist in v2.1.81. The new function has a bug in its string-source branch.

Code comparison (from strings on the compiled binaries)

v2.1.81 — only has the full loader. For string-source plugins, it resolves from the marketplace source directory, then copies to the versioned cache and uses the cache path:

// v2.1.81 full loader (minified as $l_)
if (typeof A.source === "string") {
  let X = path.join(M, A.source);         // resolve from marketplace
  O = await copyToCache(X, K, W, A, M);   // copy to versioned cache, return cache path
  // plugin.path = cache directory ✓
}

v2.1.83 — added a separate Sc_ function for session-start that skips the copy. For string-source plugins, it resolves from the marketplace directory and uses it directly, ignoring the installPath parameter entirely:

// v2.1.83 cache-only loader (minified as Sc_)
async function Sc_(A, q, K, _, z, Y) {   // Y = installPath from installed_plugins.json
  let w;
  if (typeof A.source === "string") {
    let $ = (await stat(q)).isDirectory() ? q : path.join(q, "..");
    w = path.join($, A.source);            // resolve from marketplace dir, use directly
    // installPath parameter Y is IGNORED
    // plugin.path = source directory ✗
  } else {
    if (!Y || !await exists(Y)) { /* error */ return null; }
    w = Y;                                 // correctly uses installPath
    // plugin.path = cache directory ✓
  }
  return loadPlugin(A, K, _, z, w);
}

The installPath parameter is available but only used in the else branch (object-source plugins like github/git/npm). String-source plugins ("source": "./plugin") take the wrong branch.

Impact

The source directory path flows into:

  1. hooks.json command string substitution (${CLAUDE_PLUGIN_ROOT} → source dir)
  2. Hook process environment (CLAUDE_PLUGIN_ROOT env var → source dir)
  3. Skill preprocessor directives (${CLAUDE_PLUGIN_ROOT} in SKILL.md → source dir)
  4. MCP server config (CLAUDE_PLUGIN_ROOT in stdio env → source dir)

The agent's own Bash environment is the only context that correctly uses installPath.

See also #39156 which reports the same underlying bug from the skill loader perspective.

yurukusa · 6 months ago

Workaround: set it yourself at the top of each hook:

CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins}"
exit 0
wfscot · 6 months ago
Workaround: set it yourself at the top of each hook: CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins}" exit 0

The plugin root should point to the root of the actual plugin (e.g. /home/somebody/.claude/plugins/cache/some-marketplace/some-plugin/1.0.0/ . You can obviously still hack this but it's a bit more complicated than what you suggest.

cowwoc · 6 months ago

This issue seems to be fixed in 2.1.94:

Fixed ${CLAUDE_PLUGIN_ROOT} resolving to the marketplace source directory instead of the installed cache for local-marketplace plugins on startup

It works locally for me.

github-actions[bot] · 6 months ago

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.