[BUG] Skill loader ignores installPath for string-source marketplace plugin
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?
When a marketplace plugin uses a relative string source in marketplace.json (e.g., "source": "./plugins/framework"), the skill loader resolves the plugin path relative to the marketplace installLocation from known_marketplaces.json, completely ignoring the installPath recorded in installed_plugins.json.
This means skills are loaded from the marketplace source directory (~/.claude/plugins/marketplaces/<name>/...) instead of the versioned cache directory (~/.claude/plugins/cache/<name>/<plugin>/<version>/...).
The install-time loader correctly copies plugin files to the cache and records the path in installed_plugins.json. But the session-start skill loader never reads from that cache for string-source plugins — it always resolves from the marketplace source tree.
This breaks any plugin that modifies or builds files in the cache directory after installation, since those changes are never seen by the skill loader.
Affected versions: v2.1.81 through v2.1.84 (confirmed via binary analysis)
Root cause (from binary analysis using strings on the compiled executable):
The cache-only skill loader function has two branches based on typeof entry.source:
if (typeof entry.source === "string") {
// Always resolves from marketplace directory — ignores installPath parameter
let baseDir = (await stat(marketplaceInstallLocation)).isDirectory()
? marketplaceInstallLocation
: path.join(marketplaceInstallLocation, "..");
resolvedPath = path.join(baseDir, entry.source);
} else {
// Object sources (github/git/url) correctly use installPath
if (!installPath || !await exists(installPath)) {
errors.push({ type: "plugin-cache-miss", ... });
return null;
}
resolvedPath = installPath;
}
The string-source branch should prefer installPath when available:
if (typeof entry.source === "string") {
if (installPath && await exists(installPath)) {
resolvedPath = installPath;
} else {
let baseDir = (await stat(marketplaceInstallLocation)).isDirectory()
? marketplaceInstallLocation
: path.join(marketplaceInstallLocation, "..");
resolvedPath = path.join(baseDir, entry.source);
}
}
The same issue exists in the full loader function (install-time path), which also resolves string-source plugins from the marketplace directory rather than using the recorded cache path.
What Should Happen?
The skill loader should use installPath from installed_plugins.json as the authoritative location for all installed plugins, regardless of whether entry.source is a string or an object. The marketplace source path should only be used as a fallback when installPath is not recorded or the cached directory no longer exists.
This ensures that:
- Plugins that build or modify files in the cache after installation (e.g., injecting data into skill templates) have those changes reflected at runtime
- The versioned cache created at install time is actually used at session start
- String-source and object-source plugins follow the same resolution logic
installed_plugins.jsonremains the single source of truth for where a plugin lives on disk
Error Messages/Logs
Steps to Reproduce
- Create a marketplace with a plugin that uses a relative string source in
marketplace.json:
``json``
{
"plugins": [
{
"name": "my-plugin",
"source": "./plugins/my-plugin"
}
]
}
- Install the marketplace and plugin:
````
/plugin marketplace add <marketplace-source>
/plugin install my-plugin@<marketplace-name>
- Verify
installed_plugins.jsonrecords the correct cache path:
``json``
{
"my-plugin@marketplace": [{
"installPath": "/Users/<user>/.claude/plugins/cache/<marketplace>/<plugin>/<version>"
}]
}
- Modify a file (e.g.,
SKILL.md) in the cache directory — for example, inject data between placeholder markers
- Invoke a skill from that plugin using the Skill tool
- Observe that the Skill tool header shows:
````
Base directory for this skill: /Users/<user>/.claude/plugins/marketplaces/<marketplace>/plugins/<plugin>/skills/<skill>
instead of the cache path, and the skill content reflects the unmodified marketplace source copy, not the modified cache copy
Note: The bug is silent — no errors are reported. The skill loader successfully reads from the wrong directory.
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
v2.1.80
Claude Code Version
v2.1.84
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
iTerm2
Additional Information
The investigation was performed using strings on the compiled Mach-O binary at /opt/homebrew/Caskroom/claude-code/<version>/claude. The minification mangles function names but preserves runtime property keys (installPath, source, skills, isDirectory()), making the logic fully traceable.
The relevant functions across versions:
| v2.1.83 | v2.1.84 | Role |
|---------|---------|------|
| lu$ | PF$ | Cache-only skill loader (session start) |
| iu$ | wF$ | Full loader (install time) |
| Qu$ | E7A | Orchestrator — reads installed_plugins.json, dispatches to loader |
Both v2.1.83 and v2.1.84 have identical logic for the string-source branch. The bug was introduced in v2.1.81 (changelog: "Improved plugin freshness — ref-tracked plugins now re-clone on every load to pick up upstream changes"). Last known working version is v2.1.80.
Proposed Fix
The cache-only skill loader receives installPath as its 6th parameter (confirmed in the orchestrator function which reads it from installed_plugins.json), but only uses it for object-source plugins. The fix is to check installPath first for string-source plugins too:
Before (current):
async function loadPluginSkills(entry, marketplaceInstallLocation, pluginId, enabled, errors, installPath) {
let resolvedPath;
if (typeof entry.source === "string") {
// String source: always resolves from marketplace directory, ignores installPath
let baseDir = (await stat(marketplaceInstallLocation)).isDirectory()
? marketplaceInstallLocation
: path.join(marketplaceInstallLocation, "..");
resolvedPath = path.join(baseDir, entry.source);
} else {
// Object source (github/git/url): uses installPath from installed_plugins.json
if (!installPath || !await exists(installPath)) {
errors.push({ type: "plugin-cache-miss", source: pluginId, plugin: entry.name, installPath: installPath ?? "(not recorded)" });
return null;
}
resolvedPath = installPath;
}
}
After (proposed):
async function loadPluginSkills(entry, marketplaceInstallLocation, pluginId, enabled, errors, installPath) {
let resolvedPath;
if (typeof entry.source === "string") {
// String source: use installPath from installed_plugins.json when available
if (installPath && await exists(installPath)) {
resolvedPath = installPath;
} else {
// Fallback to marketplace source resolution
let baseDir = (await stat(marketplaceInstallLocation)).isDirectory()
? marketplaceInstallLocation
: path.join(marketplaceInstallLocation, "..");
resolvedPath = path.join(baseDir, entry.source);
}
} else {
// Object source (github/git/url): uses installPath from installed_plugins.json
if (!installPath || !await exists(installPath)) {
errors.push({ type: "plugin-cache-miss", source: pluginId, plugin: entry.name, installPath: installPath ?? "(not recorded)" });
return null;
}
resolvedPath = installPath;
}
}
The same fix should apply to the full loader function (install-time path), which has the identical pattern for string-source plugins.
Additional Observations
- The bug is silent. No errors are logged. The skill loader reads from the marketplace directory successfully — it just reads the wrong copy of the file.
- The marketplace refresh function (
git pull, or delete +git cloneon failure) can overwrite files in the marketplace directory at any time, making hook-based workarounds (copying cache files to marketplace) unreliable. - The cache copy only happens at install time. The function that copies plugin files from marketplace to cache (
RpTin v2.1.83,ld_in v2.1.84) is only called by the install-time loader, not the session-start loader. installed_plugins.jsonV2 format is parsed correctly. The orchestrator readsinstallPathand passes it to the skill loader — it's just ignored for string-source plugins.- Multiple users affected. Reported independently by three users on macOS in axivo/claude#409.
This issue has 8 comments on GitHub. Read the full discussion on GitHub ↗