Settings-driven plugin auto-install (enabledPlugins) writes installed_plugins.json without populating plugins/cache/
Title
Settings-driven plugin auto-install (enabledPlugins) writes installed_plugins.json without populating plugins/cache/
Environment
- Claude Code version: 2.1.207
- OS: Linux (reproduced in Ubuntu-based Docker containers; not yet tested on macOS/Windows)
- Auth: reproduces even fully headless with no login (
claude -p "hi")
Summary
When a fresh ~/.claude config directory has enabledPlugins + extraKnownMarketplaces set in settings.json, and the marketplace repo has already been cloned into ~/.claude/plugins/marketplaces/<name> (e.g. by a prior claude plugin marketplace add), starting a session causes Claude Code to write ~/.claude/plugins/installed_plugins.json with entries that look fully installed — correct installPath, correct version, installedAt/lastUpdated timestamps — but it never actually populates the installPath directory under plugins/cache/. The plugin's skills, agents, and hooks are therefore unavailable, and any slash command from that plugin fails with Unknown command: /<plugin>:<skill>.
Because Claude Code trusts installed_plugins.json as the source of truth for "is this installed," nothing ever retries or repairs this — the broken state is permanent until the metadata is manually deleted or the plugin is reinstalled via the CLI.
Steps to reproduce
- Start from a config directory where
~/.claude/plugins/marketplaces/<marketplace-name>/already exists (a priorclaude plugin marketplace add <repo>succeeded), but~/.claude/plugins/cache/is empty or missing the target plugin. - Set in
settings.json:
``json``
{
"extraKnownMarketplaces": {
"<marketplace-name>": { "source": { "source": "github", "repo": "<owner>/<repo>" } }
},
"enabledPlugins": { "<plugin>@<marketplace-name>": true }
}
- Run
claude -p "hi"(no interactive session, no login required) against this config directory. - Inspect
~/.claude/plugins/installed_plugins.json.
Expected
Either:
plugins/cache/<marketplace-name>/<plugin>/<version>/is populated with the plugin's files before (or as part of) writing the metadata entry, or- the metadata entry is only written after the cache directory is confirmed present.
Actual
installed_plugins.json gets a fully-formed entry (installPath, version, installedAt, lastUpdated, gitCommitSha) pointing at a plugins/cache/... path that does not exist on disk. No error is surfaced. The plugin's skills are unreachable — commands like /<plugin>:<skill> fail with Unknown command, silently and permanently (nothing re-checks or repairs the cache on subsequent runs).
Reproduced deterministically 3 times in throwaway Docker containers with the same config directory shape.
Workaround
Explicitly run claude plugin install <plugin>@<marketplace-name> via the CLI — this correctly populates the cache and rewrites the metadata, even when installed_plugins.json already (incorrectly) claims the plugin is installed. claude plugin marketplace add <repo> is idempotent and safe to re-run as well. We now provision plugins this way explicitly at container-boot time instead of relying on enabledPlugins alone.
Possibly related
- #76535
- #75392
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
The same session-start write can transfer another project's record, which then loses its install
Reproduced twice on Windows 11, CLI
2.1.220— a different platform and version from the report above, but the same trigger shape (enabledPluginsset for a directory with no install record, session started, noclaude plugincommand run).The addition is not the write itself, which is already documented here. It is that the written entries can carry
installedAt/lastUpdatedvalues belonging to an existing record of a different project, and that the original record is gone afterwards. So the effect is closer to a move than to a fabrication, and the cost lands on a repo that was correctly installed and was not involved in the session at all.Instance 1 — 2026-07-31T14:32:31.795Z
A throwaway directory with three plugins in
enabledPlugins(one of which had no record anywhere on the machine) and no install record of its own. Oneclaude -psession.| record written for the throwaway dir |
installedAt| whose stamp that was ||---|---|---|
|
specialists@…|2026-07-15T18:19:31.155Z| project record of repo A ||
specialists-lifehub@…|2026-07-30T13:47:02.739Z| project record of repo A ||
specialists-shopify@…|2026-07-31T14:32:31.795Z| fresh (this plugin had no record anywhere) |All three shared one
lastUpdated(14:32:31.795Z) — a single write. Repo A's twoscope: projectrecords were no longer in the file.Instance 2 — 2026-07-31T16:01:48.239Z
Same recipe, fresh directory. One written record carried
2026-07-30T20:53:36.223Z, theinstalledAtof repo B's own project record. Repo B had lost its project install to a scratch folder.Why
installedAtis the discriminatorA real
claude plugin installsetsinstalledAtto now. A record bearing an older repo's stamp therefore did not come into being where it ended up. That is what distinguishes this from "a spurious entry was created", which is how it first looked.Why this is expensive rather than cosmetic
The victim repo gets no signal at all: no command was run there, no file in it changed,
git statusis clean. Its next session simply loads none of that plugin — and if the plugin ships SessionStart hooks, those are gone too, so the absence of any warning looks exactly like a healthy session. The state is only visible by queryinginstalled_plugins.jsonfor your ownprojectPath.Recovery is straightforward once you know (
claude plugin install <id> --scope projectfrom the real root); finding out is the hard part.Possibly the same root as #75392
That issue reports
install --scope projectoverwritinginstalled_plugins.jsonrather than merging. If the session-start write described here shares those semantics, the record loss follows directly: the write replaces the map instead of adding to it, and every other project's entry is collateral.What I am not claiming
CLAUDE.md@-import. Which factor is load-bearing is not isolated.scope: projectrecords of the same plugin id, but how the source is chosen was not measured.installed_plugins.jsonbefore and after.Thanks for the detailed repro. I ran this on 2.1.233 and on your exact version 2.1.207 (Linux, fresh
CLAUDE_CONFIG_DIR, local git marketplace with one skill plugin,extraKnownMarketplaces+enabledPluginsin settings.json,claude -p).I can confirm half of what you saw: with a marketplace added from a local path,
installed_plugins.jsonrecords aninstallPathunderplugins/cache/...that is never created. However, the plugin still worked in every run on both versions — I had the session actually invoke the plugin's skill and it loaded and executed correctly, including on subsequent runs. That's intended behavior for local marketplaces: plugins load live from the marketplace directory (so your edits take effect without a version bump), and the cache directory is only used for remote (git/GitHub) marketplaces — which I verified do populateplugins/cache/correctly when enabled via settings on both 2.1.207 and 2.1.233.We agree the install record pointing at a path that never exists is confusing, and we're considering making it reflect what actually happens for local marketplaces.
I couldn't reproduce the harmful part — the plugin's skills being absent. Could you share: (1) whether your marketplace was added from a local path or a git URL, (2) proof of absence from inside a session (e.g. ask Claude to invoke the skill by name) rather than the cache dir listing, and (3)
claude --debugoutput from startup showing any plugin load errors?🤖 Generated with Claude Code
We weren't able to reproduce this. Could you provide steps to trigger the issue — what you ran, what happened, and what you expected? This issue will be closed automatically if there's no activity within 7 days.