Settings-driven plugin auto-install (enabledPlugins) writes installed_plugins.json without populating plugins/cache/

Status Closed — not planned
Reported on v2.1.207
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jul 11, 2026 · closed Aug 24, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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

  1. Start from a config directory where ~/.claude/plugins/marketplaces/<marketplace-name>/ already exists (a prior claude plugin marketplace add <repo> succeeded), but ~/.claude/plugins/cache/ is empty or missing the target plugin.
  2. Set in settings.json:

``json
{
"extraKnownMarketplaces": {
"<marketplace-name>": { "source": { "source": "github", "repo": "<owner>/<repo>" } }
},
"enabledPlugins": { "<plugin>@<marketplace-name>": true }
}
``

  1. Run claude -p "hi" (no interactive session, no login required) against this config directory.
  2. 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

View original on GitHub ↗

3 Comments

davekokbwj · 1 month ago

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 (enabledPlugins set for a directory with no install record, session started, no claude plugin command run).

The addition is not the write itself, which is already documented here. It is that the written entries can carry installedAt / lastUpdated values 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. One claude -p session.

| 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 two scope: project records 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, the installedAt of repo B's own project record. Repo B had lost its project install to a scratch folder.

Why installedAt is the discriminator

A real claude plugin install sets installedAt to 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 status is 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 querying installed_plugins.json for your own projectPath.

Recovery is straightforward once you know (claude plugin install <id> --scope project from the real root); finding out is the hard part.

Possibly the same root as #75392

That issue reports install --scope project overwriting installed_plugins.json rather 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

  • Not the trigger. Two attempts with a barer setup (git repo + one enabled plugin + a session, no bootstrapped tree) produced no record and no transfer. The recipe that fired had three enabled plugins — one with no record anywhere — plus a directory tree with a CLAUDE.md @-import. Which factor is load-bearing is not isolated.
  • Not the selection rule. Instance 1 took from repo A, instance 2 from repo B. Both were scope: project records of the same plugin id, but how the source is chosen was not measured.
  • No claim about the implementation — only about the contents of installed_plugins.json before and after.
bcherny collaborator · 14 days ago

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 + enabledPlugins in settings.json, claude -p).

I can confirm half of what you saw: with a marketplace added from a local path, installed_plugins.json records an installPath under plugins/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 populate plugins/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 --debug output from startup showing any plugin load errors?

🤖 Generated with Claude Code

github-actions[bot] · 14 days ago

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.

Showing cached comments. Read the full discussion on GitHub ↗