Plugin enabled at both user and project scope gets only a project-scoped install record, breaking it in every other project

Status Fixed / completed
Reported on v2.1.220
Maintainer reply None cached
Activity 5 comments · opened Jul 27, 2026 · closed Aug 25, 2026

Summary

When a plugin is enabled at both user scope (~/.claude/settings.json enabledPlugins) and project scope (a repo's tracked .claude/settings.json enabledPlugins), Claude Code writes only a project-scoped install record. The user-level enablement is left with no install record that satisfies it, so the plugin is enabled-but-uninstalled in every project except the one the record is bound to.

The user-visible symptom is a misleading cache error:

Plugin "bootstrap" not cached at C:\Users\truff\.claude\plugins\cache\plugins-kit\bootstrap\0.64.1
  -> Run /plugin to refresh the plugin cache

That path exists and contains a valid .claude-plugin/plugin.json with a matching version. Nothing is wrong with the cache. The actual condition is "no install record applies to this project."

Observed on Claude Code 2.1.220, Windows 11.

Why project-only is the wrong resolution

User scope is not a competing alternative to project scope -- it is the set of all projects, of which the project scope is one element. So the two declarations are not in conflict and there is nothing to arbitrate:

  • A user-scope install satisfies both declarations. Available in every project necessarily includes availability in this one.
  • A project-scope install satisfies one and breaks the other.

Claude Code takes the union of "every project" and "this project" and returns the smaller set. This is not a precedence question ("which settings layer wins") -- a precedence rule would be defensible if the two outcomes were symmetric, and they are not. Only one of the two available resolutions leaves a stated requirement unmet, and that is the one chosen.

Note also that the two declarations are authored by different parties who cannot coordinate: a repo's tracked .claude/settings.json is committed to version control with no knowledge of what any given user has enabled globally, and the user has no knowledge of which repos they will open. Requiring them to agree on scope makes project-tracked enabledPlugins unusable for exactly the plugins it exists to guarantee.

Evidence

On one machine, 13 plugin refs, the correlation is exact:

| Enabled in | Refs | Resulting install record | Working? |
|---|---|---|---|
| User settings only | 9 | scope: user, no projectPath | yes, all 9 |
| Project settings only | 1 | scope: project + correct projectPath | yes |
| Both | 3 | scope: project only | no, all 3 |

The three affected refs were bootstrap@plugins-kit, engineer@myorg-plugins, and prototyping@myorg-plugins. All three are enabled in ~/.claude/settings.json and in C:\dev\myproject\main\.claude\settings.json.

Direct reproduction: with bootstrap@plugins-kit recorded as

"bootstrap@plugins-kit": [
  {
    "scope": "project",
    "projectPath": "C:\\dev\\myproject\\main",
    "installPath": "C:\\Users\\truff\\.claude\\plugins\\cache\\plugins-kit\\bootstrap\\0.64.1",
    "version": "0.64.1"
  }
]

a session started in C:\dev\myproject\main loads the plugin correctly, and a session started in any other directory reports the "not cached" error above. Same machine, same plugin, same moment.

The condition also survives a version change: the error was first observed naming ...\bootstrap\0.63.0, and after the plugin updated it named ...\bootstrap\0.64.1. It is not cache staleness.

Steps to reproduce

  1. Enable a plugin at user scope in ~/.claude/settings.json enabledPlugins.
  2. Enable the same plugin at project scope in some repo's tracked .claude/settings.json enabledPlugins.
  3. Let Claude Code adopt/install it (start a session in that repo).
  4. Inspect ~/.claude/plugins/installed_plugins.json -- the ref has only a scope: project record bound to that repo.
  5. Start a session in any other directory. The plugin is enabled but reports not cached at an install path that exists.

Expected

Installing at user scope, since that satisfies both declarations. Alternatively, maintaining both a user-scope and a project-scope record -- but the current outcome, a project-scoped record only, satisfies neither the user's declaration nor the plugin's actual availability.

Secondary defect: scope: "project" records with no projectPath

Two of the affected refs additionally accumulated multiple records, some of which are structurally invalid -- scope: "project" with no projectPath field at all. A project-scoped install with no project cannot be evaluated against any project.

engineer@myorg-plugins    -> 4 records, all version 0.3.4
  [0] scope=project  projectPath=<absent>          <-- invalid, and entries[0]
  [1] scope=project  projectPath=C:\dev\plugins-kit
  [2] scope=project  projectPath=<absent>          <-- invalid
  [3] scope=project  projectPath=C:\dev\myproject\main

prototyping@myorg-plugins -> 3 records, all version 0.3.1
  [0] scope=project  projectPath=<absent>          <-- invalid, and entries[0]
  [1] scope=project  projectPath=C:\dev\plugins-kit
  [2] scope=project  projectPath=C:\dev\myproject\main

In both cases an invalid record sits at index 0, which is the record the loader resolves. No configuration requests this shape, and only Claude Code writes this file.

Third defect: the error message points at the wrong subsystem

Plugin "X" not cached at <path> with the remedy Run /plugin to refresh the plugin cache is emitted when the cache is intact and the install record is the problem. This misdirects debugging entirely -- the reported path can be confirmed present, complete, and version-matched while the error persists across restarts and plugin updates. An accurate message would name the missing scope-applicable install record.

---

Footnote. Related: #79892 ("Plugin registry: user-scope record with projectPath causes silent update failure and duplicate records (no CLI escape)"). That issue reports the mirror case -- a scope: user record that carries a projectPath -- arising from the same area: enablement scope and install scope being conflated when a plugin is enabled via tracked project settings. The append-instead-of-replace behavior described there as Behavior 3 is also visible in the four-record engineer example above.

View original on GitHub ↗

3 Comments

kitaekatt · 1 month ago

Workaround, and confirmation of the diagnosis

Remediating this client-side fixes it, which confirms the analysis above: the plugin becomes usable in every project as soon as a pathless user-scope record exists alongside the project-scoped one. Nothing about the cache changes.

The remediation, against ~/.claude/plugins/installed_plugins.json:

  1. For a ref that should be available at user scope, check whether any of its records is both pathless and scope: "user".
  2. If none is, derive a new record from the authoritative existing record -- copy installPath, version, gitCommitSha -- set scope: "user", and omit projectPath entirely.
  3. Insert it at index 0.
  4. Leave every existing record untouched, including the project-scoped one.

Guards that turned out to matter:

  • Only ever synthesize a user-scope pathless record. A pathless scope: "project" record is exactly the invalid shape described in the secondary defect above.
  • Never rewrite an existing record's scope in place when it carries projectPath. That manufactures the user-scope+projectPath chimera from #79892 -- one defect traded for another.
  • Verify installPath exists on disk before adding, so a genuinely missing install isn't papered over with a record that points at nothing.
  • No-op when the record is already present. This file is shared, and its mtime is observable to other tooling, so an unconditional rewrite has side effects beyond the content.

Index 0 because resolution appears positional and the real semantics aren't documented anywhere I could find. A pathless user record is valid in every project, so placing it first is safe under either a positional or a scope-aware reading.

Result on the affected machine: bootstrap@plugins-kit went from one project-scoped record to two -- pathless user at index 0, the original project record untouched at index 1 -- and sessions started outside the bound project stopped reporting not cached. Separately deleting the invalid pathless-project records took the two refs that had 4 and 3 records down to 2 well-formed ones each.

Stating the obvious: this is a workaround, not a fix. It is third-party tooling writing a file Claude Code owns, which is not a position a plugin should be in -- and if Claude Code later starts writing the user-scope record itself, any client-side rule like this has to be written carefully enough not to fight it. Posting it as reproduction evidence, and in case it is useful to whoever picks this up.

rwz · 1 month ago

Confirming on macOS with the same version (2.1.220), and adding a deterministic repro plus a trigger condition that I think is narrower than the issue currently states.

The table here treats this as a static resolution problem — enabled at both scopes yields only a project record. On this machine it was destructive: four plugins that had working pathless user-scope records for weeks were converted in place to project-scoped records pinned to a repo I had just cloned. installedAt survived the rewrite while lastUpdated was bumped, so these are the original user-scope records mutated, not fresh records written in place of a missing one.

Trigger: the rewrite only fires when the project's tracked .claude/settings.json declares at least one plugin that is not yet installed. That forces an install pass, and the pass rewrites the records of every ref in the declaration — including already-user-scope refs it had no need to touch.

Repro (macOS, 2.1.220):

  1. claude plugin install plugin-a@my-marketplace --scope user. Confirm installed_plugins.json shows "scope": "user" with no projectPath.
  2. Pick plugin-b@my-marketplace, not installed.
  3. Create a git repo with a tracked .claude/settings.json:

``json
{ "enabledPlugins": { "plugin-a@my-marketplace": true, "plugin-b@my-marketplace": true } }
``

  1. Run one session in it: claude -p "reply with the single word: ok".

Result: plugin-a flips from scope: user to scope: project with projectPath: <repo>, installedAt preserved, lastUpdated bumped. plugin-b gets a fresh project record (installedAt == lastUpdated). plugin-a stops loading in every other project.

Negative control: the same repo declaring only plugin-a changes nothing. So it's the missing ref that drags healthy user-scope refs down with it. This widens the coordination problem described above — the parties who cannot coordinate now include whoever adds a new plugin to the repo's settings months later, which retroactively breaks that plugin for everyone who had it globally.

Matching field sequence: a repo cloned at 13:56:26 local time, installed_plugins.json rewritten at 13:56:34 — eight seconds later, six refs re-pinned to it, four carrying months-old installedAt values from user-scope installs. Sessions started before that write had the plugins; sessions started after did not.

Two adjacent symptoms worth folding in:

  • claude plugin list reports Status: ✔ enabled for every affected plugin, because the user-scope enabledPlugins: true toggle survives with nothing to satisfy it. Nothing in that output reveals the ref is pinned to another directory, which makes this very hard to self-diagnose — I went looking for malformed skill frontmatter first.
  • The install pass also installs refs the user never enabled at user scope and had deliberately declined, since it satisfies the repo's declaration unconditionally. Related to #71691.

The client-side remediation in the comment above (add a pathless user-scope record alongside the project one) works here too.

rwz · 1 month ago

Two corrections/additions to my comment above, both observed directly while remediating.

1. The blast radius is not limited to refs named in the declaration. I said the install pass "rewrites the records of every ref in the declaration." It also rewrites refs that are not in the declaration, in the opposite direction. When I ran the repro in an unrelated throwaway repo, the four refs that had been pinned to the earlier project were reset to scope: user with projectPath dropped — repairing them by accident, at that pass's timestamp, without ever being mentioned in the throwaway repo's settings.

In my case that was convenient, but it is the same defect pointing the other way: a ref someone deliberately installed at project scope for repo X loses that pin because of an unrelated session in repo Y. Refs from a different marketplace than the one the pass touched were left alone, so the rewrite may be scoped per-marketplace — I haven't isolated that, so treat the mechanism as unconfirmed and the observation as the claim: undeclared same-marketplace refs had their scope rewritten.

2. Disabling the plugin at higher-precedence local scope does not prevent the install. A tracked .claude/settings.json with {"enabledPlugins": {"plugin-b@my-marketplace": true}} alongside an untracked .claude/settings.local.json with {"enabledPlugins": {"plugin-b@my-marketplace": false}} still installs plugin-b on the next session, and still triggers the scope rewrite. settings.local.json normally takes precedence over the shared project file, so the install pass appears to key off the presence of the declaration rather than the effective enabled state.

That removes the obvious client-side mitigation. If you don't want a plugin your repo's tracked settings declare, there is no local-only way to say so: uninstalling it just means the next session in that repo reinstalls it and re-pins the co-declared refs along with it. The only durable fix is editing the tracked settings file, which for a shared repo means changing it for everyone.

Showing cached comments. Read the full discussion on GitHub ↗