Plugin enabled at both user and project scope gets only a project-scoped install record, breaking it in every other project
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
- Enable a plugin at user scope in
~/.claude/settings.jsonenabledPlugins. - Enable the same plugin at project scope in some repo's tracked
.claude/settings.jsonenabledPlugins. - Let Claude Code adopt/install it (start a session in that repo).
- Inspect
~/.claude/plugins/installed_plugins.json-- the ref has only ascope: projectrecord bound to that repo. - Start a session in any other directory. The plugin is enabled but reports
not cachedat 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.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
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:scope: "user".installPath,version,gitCommitSha-- setscope: "user", and omitprojectPathentirely.Guards that turned out to matter:
user-scope pathless record. A pathlessscope: "project"record is exactly the invalid shape described in the secondary defect above.scopein place when it carriesprojectPath. That manufactures the user-scope+projectPath chimera from #79892 -- one defect traded for another.installPathexists on disk before adding, so a genuinely missing install isn't papered over with a record that points at nothing.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-kitwent from one project-scoped record to two -- pathlessuserat index 0, the original project record untouched at index 1 -- and sessions started outside the bound project stopped reportingnot 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.
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.
installedAtsurvived the rewrite whilelastUpdatedwas 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.jsondeclares 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):
claude plugin install plugin-a@my-marketplace --scope user. Confirminstalled_plugins.jsonshows"scope": "user"with noprojectPath.plugin-b@my-marketplace, not installed..claude/settings.json:``
json
``{ "enabledPlugins": { "plugin-a@my-marketplace": true, "plugin-b@my-marketplace": true } }
claude -p "reply with the single word: ok".Result:
plugin-aflips fromscope: usertoscope: projectwithprojectPath: <repo>,installedAtpreserved,lastUpdatedbumped.plugin-bgets a fresh project record (installedAt == lastUpdated).plugin-astops loading in every other project.Negative control: the same repo declaring only
plugin-achanges 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.jsonrewritten at 13:56:34 — eight seconds later, six refs re-pinned to it, four carrying months-oldinstalledAtvalues 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 listreportsStatus: ✔ enabledfor every affected plugin, because the user-scopeenabledPlugins: truetoggle 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 client-side remediation in the comment above (add a pathless user-scope record alongside the project one) works here too.
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: userwithprojectPathdropped — 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.jsonwith{"enabledPlugins": {"plugin-b@my-marketplace": true}}alongside an untracked.claude/settings.local.jsonwith{"enabledPlugins": {"plugin-b@my-marketplace": false}}still installsplugin-bon the next session, and still triggers the scope rewrite.settings.local.jsonnormally 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.