[BUG] Fix ${CLAUDE_PLUGIN_ROOT} in command markdown OR support local project plugin installation

Open 💬 25 comments Opened Oct 11, 2025 by bartolli
💡 Likely answer: A maintainer (stbenjam, contributor) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Currently, the ${CLAUDE_PLUGIN_ROOT} environment variable, which is documented as the way for plugins to reference their own files, only works in JSON configurations (hooks, MCP servers) but not in command markdown files. This makes it impossible for /plugin commands to execute bundled scripts.

What Should Happen?

Support local plugin installation
/plugin install my-plugin@marketplace --local

  • Install to .claude/plugins/ in the project directory
  • Commands can use project-relative paths: node .claude/plugins/codanna-plugin/scripts/helper.js
  • Consistent with existing .claude/commands/ and .claude/agents/ patterns

Error Messages/Logs

 ▐▛███▜▌   Claude Code v2.0.22
▝▜█████▛▘  Sonnet 4.5 · Claude Max
  ▘▘ ▝▝    /Users/bartolli/Projects/codanna

> /test is running… 
  ⎿  Allowed 1 tools for this command

⏺ I'll execute the command to check the CLAUDE_PLUGIN_ROOT environment variable and run the requested command.

⏺ Bash(echo "CLAUDE_PLUGIN_ROOT=$CLAUDE_PLUGIN_ROOT")
  ⎿  CLAUDE_PLUGIN_ROOT=

⏺ The CLAUDE_PLUGIN_ROOT environment variable is not set (empty/undefined).

  Since the variable is undefined, I cannot execute the command:
  node ${CLAUDE_PLUGIN_ROOT}/scripts/context-provider.js find "Exit codes" --limit=1

  This would expand to:
  node /scripts/context-provider.js find "Exit codes" --limit=1

  Which would fail because there's no valid path.

  The value of CLAUDE_PLUGIN_ROOT is: undefined/empty

  node:internal/modules/cjs/loader:1386
    throw err;
    ^

  Error: Cannot find module '/scripts/context-provider.js'
      at Function._resolveFilename (node:internal/modules/cjs/loader:1383:15)
      at defaultResolveImpl (node:internal/modules/cjs/loader:1025:19)
      at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1030:22)
      at Function._load (node:internal/modules/cjs/loader:1192:37)
      at TracingChannel.traceSync (node:diagnostics_channel:322:14)
      at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)
      at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
      at node:internal/main/run_main_module:36:49 {
    code: 'MODULE_NOT_FOUND',
    requireStack: []
  }

  Node.js v22.20.0

Steps to Reproduce

Custom command installed via /plugin

description: Analyze code

Execute: node ${CLAUDE_PLUGIN_ROOT}/scripts/analyzer.js

Results in:
Error: Cannot find module '/scripts/analyzer.js'

Because ${CLAUDE_PLUGIN_ROOT} expands to nothing, making it impossible for plugin commands to run bundled scripts that analyze project files, parse JSON, call APIs, or any other programmatic task.

Claude Model

None

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.0.14

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

Many plugins need helper scripts for:

  • Complex data processing (JSON parsing, formatting)
  • API integrations
  • Code analysis tools
  • Build system integrations

Without this fix, plugin commands are limited to simple prompt engineering without any computational capabilities.

View original on GitHub ↗

25 Comments

bartolli · 9 months ago

@bcherny Hey Boris, could you please escalate this?

Currently, the only workaround is manually adding the plugin root directory path as an environment variable in settings.local.json, which doesn't scale well across users or installations.

It would be really useful if plugin skills, slash commands and subagents could natively access plugin scripts via a standardized environment variable (like CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR). This would enable more creative automation workflows without requiring Claude to handle trivial script orchestration or computational tasks!

Thanks a lot!

dkmaker · 8 months ago

Well the local in the repo can work but it shouldn't actually just be in the ~/.claude folder where I presume the scripts are located but the Claude Project env should be available so the scripts can do a cd to the folder by resolving that

Or both options - I prefer the update to follow the plugin so if you have many repos and expect a new version then it will be a mess handling

There is several combinations though and many usecases to cover but it's indeed critical somehow to get it solved

dkmaker · 8 months ago

Any update? - Skills can somehow fix it but its still a bad solution imo i need a reference to the plugin folder

sdh07 · 8 months ago

any update: that is really a nasty bug

rhuss · 7 months ago

I ran into this same issue while building a plugin that needs to execute Python scripts. Since ${CLAUDE_PLUGIN_ROOT} isn't reliably available in command markdown, I put together a workaround that's been working great in production. Thought I'd share the implementation in case it helps other plugin developers.

The idea is to create a small resolver script that figures out where your plugin lives by reading ~/.claude/plugins/installed_plugins.json. On first use, your plugin bootstraps this script into the user's project at .claude/cpr.sh. After that, all your commands just use $(.claude/cpr.sh) instead of ${CLAUDE_PLUGIN_ROOT}.

How it flows:

  1. User runs your plugin command for the first time
  2. A bootstrap skill creates .claude/cpr.sh in their project (happens once)
  3. This script locates your plugin via the installed plugins JSON
  4. All your commands use $(.claude/cpr.sh) to reference plugin files
  5. Your plugin can now execute bundled scripts, call APIs, process files, etc.

The nice thing is it's backwards compatible - the script checks CLAUDE_PLUGIN_ROOT first, so when the bug gets fixed, it'll automatically use the official variable. And if you want to remove the workaround entirely, it's just a simple find/replace.

[!WARNING] This approach is not yet hardened for setup with spaces in the path to the plugin directory. Be careful about that or let it fix by claude :-)

Implementation Details

1. Create the bootstrap skill at skills/plugin-env-setup/SKILL.md:

<details>
<summary>Click to show SKILL.md</summary>

````markdown
---
description: Bootstrap plugin environment by creating .claude/cpr.sh resolver script. Use at the start of any command that needs to execute plugin scripts.
---

Plugin Environment Setup Skill

Creates .claude/cpr.sh (Claude Plugin Root resolver) if it doesn't exist.

Purpose

Solves the bootstrap problem where:

  • Commands need to execute scripts from the plugin directory
  • ${CLAUDE_PLUGIN_ROOT} is unreliable (issue #9354)
  • Need a fallback mechanism to locate the plugin

Task

Step 1: Check if bootstrap is needed

if [ -f ".claude/cpr.sh" ]; then
    echo "✓ Plugin environment already bootstrapped"
    # Skip to Step 3
else
    echo "📦 Bootstrapping plugin environment..."
    # Continue to Step 2
fi

If .claude/cpr.sh exists: Skip to Step 3.
If not: Continue to Step 2.

---

Step 2: Create .claude/cpr.sh (only if needed)

ONLY execute if Step 1 found cpr.sh missing.

mkdir -p .claude

cat > .claude/cpr.sh << 'CPREOF'
#!/bin/bash
# Claude Plugin Root (CPR) Resolver

# IMPORTANT: Change this to match your plugin's name/identifier
PLUGIN_NAME="myplugin"

# Try CLAUDE_PLUGIN_ROOT first
if [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}" ]; then
    echo "${CLAUDE_PLUGIN_ROOT%/}"
    exit 0
fi

# Fallback: jq lookup
if command -v jq &> /dev/null; then
    PLUGIN_ROOT=$(jq -r '.plugins | to_entries[] | select(.key | contains("'"$PLUGIN_NAME"'")) | .value.installPath' "${HOME}/.claude/plugins/installed_plugins.json" 2>/dev/null)
    if [ -n "$PLUGIN_ROOT" ] && [ -d "$PLUGIN_ROOT" ]; then
        echo "${PLUGIN_ROOT%/}"
        exit 0
    fi
fi

# Fallback: Python lookup
PLUGIN_ROOT=$(python3 -c "
import json
try:
    with open('${HOME}/.claude/plugins/installed_plugins.json') as f:
        plugins = json.load(f)['plugins']
        for key, value in plugins.items():
            if '$PLUGIN_NAME' in key:
                print(value['installPath'].rstrip('/'))
                break
except: pass
" 2>/dev/null)

if [ -n "$PLUGIN_ROOT" ] && [ -d "$PLUGIN_ROOT" ]; then
    echo "$PLUGIN_ROOT"
    exit 0
fi

echo "Error: Could not locate $PLUGIN_NAME plugin" >&2
exit 1
CPREOF

chmod +x .claude/cpr.sh
echo "✓ Created .claude/cpr.sh"

---

Step 3: Run your plugin setup

Use the resolver to locate and run your plugin scripts:

# Run setup with dependencies
bash "$(.claude/cpr.sh)/scripts/setup.sh" ${DEPENDENCIES}

# Execute Python script
"$(.claude/cpr.sh)/scripts/run.sh" analyze.py --input data.json

# Run Node.js tool
node "$(.claude/cpr.sh)/tools/parser.js" --file report.pdf

Usage in Commands

### Step 0: Environment Setup

Invoke the `myplugin:plugin-env-setup` skill to bootstrap the environment.

After setup, use `"$(.claude/cpr.sh)/scripts/..."` to reference plugin files.

````

</details>

2. Register the skill in your plugin.json (tested with top-level plugin.json instead of in .claude-plugin/plugin.json, you can also try to just _not_ add the skill and it should be automatically be detected) :

{
  "skills": {
    "plugin-env-setup": {
      "description": "Bootstrap plugin environment",
      "path": "skills/plugin-env-setup/SKILL.md"
    }
  }
}

3. Use it in your commands - here's an example:

### Step 0: Environment Setup

Invoke the `myplugin:plugin-env-setup` skill to bootstrap the environment.

### Step 1: Execute your scripts

```bash
# Now you can reliably execute plugin scripts
"$(.claude/cpr.sh)/scripts/run.sh" analyze.py --input data.json
node "$(.claude/cpr.sh)/tools/parser.js" --file report.pdf

I'm using this in a production plugin with 10 commands that execute Python scripts for text analysis, PDF processing, and code validation. It's been solid across multiple projects and machines. The bootstrap happens once per project, then everything just works.

Obviously this is a workaround and native ${CLAUDE_PLUGIN_ROOT} support would be ideal, but this gets plugins fully functional in the meantime. Hope it helps! 🚀

Assisted-By: 🤖 Claude Code (but with a real human behind :-)

okbrown · 7 months ago

Hey, this is nice, but this doesn't work for me. It seems the plugin.json does not understand skills this fails installation when adding it to my existing marketplace.

okbrown · 7 months ago
Hey, this is nice, but this doesn't work for me. It seems the plugin.json does not understand skills this fails installation when adding it to my existing marketplace.

With that said despite it displaying as /<plugin>:plugin-env-setup I was able to get claude to run the skill manually.
Defeats the purpose of having a smoother onboarding flow but I get it.

rhuss · 7 months ago

You could leave out the skills part (it works for me in the latest claude versions), then you get some auto-detection which should work as well. Actually I forgot to mention that I usually put plugin.json top-level in the plugin directory, I hate all those .dot files that you need to commit (they should be only for runtime information that is not committed to git). So, could be that the relative path only works when top-level in the plugin directory, but as said, you can just ommit this (should also work but even less tested :-) . I've updated that info in the comment above.

Said that, I really would also love to not have to apply this worakround, and strangely sometimes it works, sometimes not. E.g. I _think_ $CLAUDE_PLUGIN_ROOT is available for Bash() tooling in _skills_ but not in _commands_. So another workaround that you can try is to create a skill instead of a command and then have a command that just says: Call that skill. worth testing ....

jkappers · 7 months ago

EDIT: Simplified significantly

Thanks @rhuss for the idea to let skills resolve paths.

In case it's helpful, it was enough for me to simply create a skill and add an instruction in the skill metadata for the main agent to use the skill to resolve resource paths before launching any of my plugin subagents and providing the path to the subagent prompt.

https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview#level-1-metadata-always-loaded

Directory structure (partial):

/skills
  /resources
    /docs
      adr_guide.md
      spec_guide.md
    SKILL.md

````
---
name: resources
description: Returns plugin resource path. Invoke BEFORE launching any your-plugin-name agents and pass the path to them.
---

Resource Path

This skill's folder location is the {resources_folder}. Pass it to agents in prompts:

Resources folder: {resources_folder}

````

Tool usage log:

> /your-plugin-name:design is running… specs/2025-12-04-jwt-integration/README.md 

⏺ I'll execute the design command for the JWT integration specification.

  First, let me validate the input and read the specification:

⏺ Read(specs/2025-12-04-jwt-integration/README.md)
  ⎿  Read 147 lines

⏺ Search(pattern: "docs/adr/[0-9][0-9][0-9][0-9]-*.md")
  ⎿  Found 0 files 

⏺ Skill(your-plugin-name:resources)

⏺ Now I have all the information needed.

  - Spec path: specs/2025-12-04-jwt-integration/README.md
  - Spec directory: specs/2025-12-04-jwt-integration
  - ADR directory: docs/adr
  - ADR number: 0001 (no existing ADRs found)
  - Resources folder: /Users/jkappers/.claude/plugins/marketplaces/your-plugin-name/skills/resources

  Let me invoke the technical-designer agent:

⏺ your-plugin-name:technical-designer(Create technical design for JWT integration)
  Read(specs/2025-12-04-jwt-integration/README.md)
  Read(~/.claude/plugins/marketplaces/your-plugin-name/skills/resources/docs/spec_guide.md)
  Read(~/.claude/plugins/marketplaces/your-plugin-name/skills/resources/docs/adr_guide.md)
  Read(~/.claude/plugins/marketplaces/your-plugin-name/skills/resources/templates/adr-template.md)
bartolli · 7 months ago

I ran a test with v2.0.41 when both env variables were added to bash command processing:

CHANGELOG v2.0.41:

  • Slash Commands: Added CLAUDE_PROJECT_DIR and CLAUDE_PLUGIN_ROOT env vars to bash command processing

v2.0.41 output:
CLAUDE_PROJECT_DIR = /Users/bartolli/Projects/sdk-rust
CLAUDE_PLUGIN_ROOT = undefined

v2.0.60 output (current):
CLAUDE_PROJECT_DIR = undefined
CLAUDE_PLUGIN_ROOT = undefined

Regression confirmed. CLAUDE_PROJECT_DIR worked in v2.0.41 and broke somewhere between 2.0.41 - 2.0.60.

Tested via npx @anthropic-ai/claude-code@2.0.41 for reproducibility.

here's the test slash command:

---
allowed-tools: Bash(node:*)
description: Test CLAUDE_PROJECT_DIR and CLAUDE_PLUGIN_ROOT env vars to bash command processing
model: haiku
---

## This is a test command

CLAUDE_PLUGIN_ROOT = !`node -e "console.log(process.env.CLAUDE_PLUGIN_ROOT)"`
CLAUDE_PROJECT_DIR = !`node -e "console.log(process.env.CLAUDE_PROJECT_DIR)"`

## Your task

Answer the question: What's the value of CLAUDE_PLUGIN_ROOT & CLAUDE_PROJECT_DIR env variables?

Hope this helps track down the regression in setting env vars upon Node init.

elliott-warkus · 7 months ago

v2.0.64 seems to have broken CLAUDE_PLUGIN_ROOT for hooks JSON as well. A plugin hook that was working in v2.0.61 is broken in v2.0.64 because CLAUDE_PLUGIN_ROOT is resolving to ~/.claude/plugins/cache/\<marketplace>/\<plugin>/unknown/.

deeepanshu · 6 months ago

~~Any updates? same issue on 2.0.76 with plugin slash commands.~~

noahlz · 6 months ago

I noticed that when a skill launches, Claude receives a message like the following:

Base directory for this skill: /User/noahlz/.claude/plugins/cache/my-marketplace/my-plugin/0.1.0/skills/my-skill

After Claude itself used this information to recover from a "script not found" error during skill execution, I added instructions to my skill to read this message and save it off for later use.

Seems formalizing the "Skill Base Directory" as a value available for skill execution is a reasonable next step!

Looks like this is an issue even with the example skills that Anthropic publishes, btw:

https://github.com/anthropics/skills/blob/69c0b1a0674149f27b61b2635f935524b6add202/skills/docx/ooxml.md?plain=1#L276

ht2 · 5 months ago

This explains why my ralph hooks have stopped working, seemingly since I upgraded to 2.1.*. Claude Code recommended I let people know that it's killing the official ralph-loop plugin...

<img width="1324" height="491" alt="Image" src="https://github.com/user-attachments/assets/ae37d096-02e3-4fa6-9df5-1d691afab874" />

bman654 · 5 months ago

My relatively simple work-around:

Add this hook to my plugin:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "echo MY_PLUGIN_ROOT=\"${CLAUDE_PLUGIN_ROOT}\""
          }
        ]
      }
    ]
  }
}

then in my Markdown files I just do stuff like:

For each validated finding, pass the finding description to the bash script to format the message:

\`\`\`bash
{MY_PLUGIN_ROOT}/scripts/review-pull-request-format-comment.sh "{finding_description}"
\`\`\`

or:

Follow this workflow: {MY_PLUGIN_ROOT}/reference/whatever.md

And in actual scripts, instead of using CLAUDE_PLUGIN_ROOT (empty) or MY_PLUGIN_ROOT (not an actual env var),
I have code like:

# Get plugin root (one level up from scripts/)
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

For the Markdown, the LLM is smart enough to just substitute the value passed into the system prompt via the hook.
And the script detects its own location and finds the plugin root.

Seems to be working, though you need to remember to tell Claude to pass the variable to any of your custom agents that need it.

sstraus · 5 months ago

Marketplace Plugin Developer Perspective

We maintain a marketplace plugin suite (wiz + hud) with 25+ agents, 23 commands, and 11 skills that rely heavily on CLI scripts for interactive workflows (journal management, story tracking, code review orchestration).

The core problem

Our skills need to instruct Claude to run bundled Node.js scripts:

node <plugin_path>/scripts/journal-cli.js search <keyword>
node <plugin_path>/scripts/stories-cli.js list

Since ${CLAUDE_PLUGIN_ROOT} doesn't resolve in skill/command markdown, we inject a text pointer via SessionStart hook:

// session-start.js (hook — CLAUDE_PLUGIN_ROOT works here)
console.log(`**WIZ_SCRIPTS:** \`${path.join(PLUGIN_ROOT, 'scripts')}\``);

Claude sees WIZ_SCRIPTS: ~/.claude/plugins/cache/marketplace/wiz/3.2.0/scripts as text and uses it when composing Bash commands. This works but:

  1. It's fragile — the path is text Claude reads, not an env var. Claude occasionally misremembers or truncates it after compaction.
  2. Can't use ! backtick — dynamic context (!node $WIZ_SCRIPTS/...`) fails because $WIZ_SCRIPTS` isn't a real env var at execution time.
  3. Every CLI interaction requires Bash authorization — there's no way to pre-authorize plugin script execution.

What would fix this

In priority order:

  1. Fix the regressionCLAUDE_PLUGIN_ROOT should be a real env var available in ! backtick commands and Bash tool calls triggered from skill/command markdown (this worked in v2.0.41 per @bartolli's report).
  1. Add CLAUDE_PLUGIN_ROOT to skill frontmatter variable expansion — so ${CLAUDE_PLUGIN_ROOT} in SKILL.md body gets replaced at load time, just like $ARGUMENTS does.
  1. Support plugin-scoped Bash pre-authorization — allow plugin.json to declare patterns that don't trigger auth prompts, e.g.:

``json
{
"permissions": {
"bash": ["node ${CLAUDE_PLUGIN_ROOT}/scripts/*"]
}
}
``

Item 1 alone would unblock the entire plugin ecosystem. Items 2-3 would make it delightful.

Workaround we're using

SessionStart hook injects path as text → Claude reads it → Claude composes Bash commands with that path → user approves each Bash call. It works, but it's 3 layers of indirection for what should be node ${CLAUDE_PLUGIN_ROOT}/scripts/foo.js.

sstraus · 5 months ago

+1

stbenjam contributor · 4 months ago

For skills, SKILL.md gets loaded correctly -- so Claude obviously knows where it lives. How hard is it to wire in $CLAUDE_PLUGIN_ROOT?

noahlz · 4 months ago

2.1.69 released today - includes this morsel:

Added ${CLAUDE_SKILL_DIR} variable for skills to reference their own directory in SKILL.md content

Not strictly for "plugins" but I suspect solves this problem for non-trivial skills.

stbenjam contributor · 4 months ago

This is great, I noticed my skills were more easily finding their scripts. CLAUDE_PLUGIN_ROOT really needs to be set though too.

askpatrickw · 4 months ago
2.1.69 released today - includes this morsel: > Added ${CLAUDE_SKILL_DIR} variable for skills to reference their own directory in SKILL.md content Not strictly for "plugins" but I suspect solves this problem for non-trivial skills.

This fix breaks using skills across many agents. Its a Claude only fix that requires modifying reusuable skills breaking them for other agents.

noahlz · 4 months ago
This fix breaks using skills across many agents. Its a Claude only fix that requires modifying reusuable skills breaking them for other agents.

Yeah, I think this is only an issue when using a Claude Code "plugin" skill, and is not an issue if you merely copy-paste the skill into your project .claude/ directory

Supporting "plugin" skills would have to be added to this standard, I suppose?

https://agentskills.io/skill-creation/using-scripts#referencing-scripts-from-skill-md

Do I understand this correctly?

askpatrickw · 4 months ago

In my case, we put them in ~/.claude/skills not a local project skills directory.

They are placed there and updated by Skillshare; using a symlink.
They work flawlessly with OpenCode.

yurukusa · 3 months ago

The ${CLAUDE_PLUGIN_ROOT} variable not expanding in command markdown is a known limitation. Here's a hook-based workaround:

INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.user_prompt // empty' 2>/dev/null)
if echo "$PROMPT" | grep -qE '^/'; then
    PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins}"
    if [ -d "$PLUGIN_ROOT" ]; then
        echo "{\"hookSpecificOutput\":{\"additionalContext\":\"Plugin root path: $PLUGIN_ROOT. Use this path for any script references.\"}}"
    fi
fi
exit 0

Until native local install is supported, you can manually set up project-local plugins:

PLUGIN_NAME="$1"
PLUGIN_SRC="$HOME/.claude/plugins/$PLUGIN_NAME"
LOCAL_DIR=".claude/plugins/$PLUGIN_NAME"
if [ ! -d "$PLUGIN_SRC" ]; then
    echo "Plugin $PLUGIN_NAME not found at $PLUGIN_SRC"
    exit 1
fi
mkdir -p "$LOCAL_DIR"
cp -r "$PLUGIN_SRC/"* "$LOCAL_DIR/"
find "$LOCAL_DIR" -name "*.md" -exec sed -i "s|\${CLAUDE_PLUGIN_ROOT}|$LOCAL_DIR|g" {} +
echo "Installed $PLUGIN_NAME locally at $LOCAL_DIR"

The simplest workaround for commands that need to run bundled scripts:

<!-- .claude/commands/test.md -->
Run the test script. The plugin is installed at: $HOME/.claude/plugins/my-plugin
```bash
node $HOME/.claude/plugins/my-plugin/scripts/helper.js
Use `$HOME` (which bash expands) instead of `${CLAUDE_PLUGIN_ROOT}` (which only expands in JSON).
tmchow · 2 months ago

oof.. i just banged my head against the wall trying to debug what was going on, not realizing this was a bug for ${CLAUDE_PLUGIN_ROOT}.