[BUG] Skill loader ignores installPath for string-source marketplace plugin

Resolved 💬 8 comments Opened Mar 26, 2026 by fmunteanu Closed May 22, 2026

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.json remains the single source of truth for where a plugin lives on disk

Error Messages/Logs

Steps to Reproduce

  1. Create a marketplace with a plugin that uses a relative string source in marketplace.json:

``json
{
"plugins": [
{
"name": "my-plugin",
"source": "./plugins/my-plugin"
}
]
}
``

  1. Install the marketplace and plugin:

``
/plugin marketplace add <marketplace-source>
/plugin install my-plugin@<marketplace-name>
``

  1. Verify installed_plugins.json records the correct cache path:

``json
{
"my-plugin@marketplace": [{
"installPath": "/Users/<user>/.claude/plugins/cache/<marketplace>/<plugin>/<version>"
}]
}
``

  1. Modify a file (e.g., SKILL.md) in the cache directory — for example, inject data between placeholder markers
  1. Invoke a skill from that plugin using the Skill tool
  1. 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 clone on 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 (RpT in v2.1.83, ld_ in v2.1.84) is only called by the install-time loader, not the session-start loader.
  • installed_plugins.json V2 format is parsed correctly. The orchestrator reads installPath and 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.

View original on GitHub ↗

This issue has 8 comments on GitHub. Read the full discussion on GitHub ↗