[BUG] `claude plugin install --scope project` overwrites `installed_plugins.json` instead of merging

Status Fixed / completed
Reported on v2.1.195
Maintainer reply None cached
Activity 8 comments · opened Jul 7, 2026 · closed Aug 17, 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?

Each call to claude plugin install <plugin> --scope project overwrites ~/.claude/plugins/installed_plugins.json with a fresh single-entry file, erasing all previously registered plugins. After installing N plugins sequentially, only the last one survives in the registry.

The Plugins UI Errors tab then reports every project-scoped plugin as _"enabled in project settings but isn't installed here"_. Attempting to fix this by re-running claude plugin install for each missing plugin makes it worse — each fix wipes the entries installed before it.

What Should Happen?

claude plugin install should read the existing installed_plugins.json, merge the new plugin entry into the existing "plugins" object, and write the result back. All previously installed plugins should remain registered after installing a new one.

Error Messages/Logs

No explicit error is shown. The only observable signals are:

- The Plugins UI Errors tab shows N errors (one per project-scoped plugin, except the last-installed)
- Each error reads: `Plugin "X" is enabled in project settings but isn't installed here`
- Running `claude plugin install X --scope project` reports `✔ Successfully installed` but silently removes all other entries from the registry

Steps to Reproduce

  1. Install two or more plugins at project scope sequentially:

``
claude plugin install plugin-a@marketplace --scope project
claude plugin install plugin-b@marketplace --scope project
``

  1. Inspect ~/.claude/plugins/installed_plugins.json
  2. Observe that only plugin-b is present — plugin-a's entry has been overwritten

Additional context: Discovered after brew upgrade claude to 2.1.195. Plugins installed before the upgrade were correctly present in installed_plugins.json. The overwrite behavior began with the first claude plugin install run after the upgrade.

Claude Model

Not sure / Multiple models

Is this a regression?

Yes, this worked in a previous version

Last Working Version

_No response_

Claude Code Version

2.1.195 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

The plugin cache files under ~/.claude/plugins/cache/ are not affected — all plugin content is correctly downloaded. Only the installation registry (installed_plugins.json) is corrupted by the overwrite.

Workaround: Manually reconstruct ~/.claude/plugins/installed_plugins.json by merging all entries:

{
  "version": 2,
  "plugins": {
    "plugin-a@marketplace": [
      {
        "scope": "project",
        "installPath": "~/.claude/plugins/cache/marketplace/plugin-a/<version>",
        "version": "<version>",
        "installedAt": "<timestamp>",
        "lastUpdated": "<timestamp>",
        "gitCommitSha": "<sha>",
        "projectPath": "/path/to/project"
      }
    ],
    "plugin-b@marketplace": [ "..." ]
  }
}

Note: The direct consequence of this is installing skills only to see the older skills with errors, next. Trying to follow the instruction to reinstall the plugins via cli does not work, since installing one uninstall the others.

View original on GitHub ↗

7 Comments

felipepenha · 1 month ago

Please, if anyone could check this. Skills obtained through a custom marketplace are basically unusable at this point.

yasyf · 1 month ago

We're hitting what looks like the same non-merging registry write, but through an implicit trigger — no claude plugin install command involved — and with a different blast radius. Sharing because it suggests the defective write path fires far more broadly than the CLI repro here.

Environment: Claude Code 2.1.207, macOS (Darwin 25.5), plugins from git marketplaces with autoUpdate: true, enabled via enabledPlugins in each repo's .claude/settings.json.

Symptom: a session that uses a plugin (e.g. invokes one of its skills) rewrites the ~/.claude/plugins/installed_plugins.json entries for every plugin the current project enables — each per-plugin array is replaced wholesale with a single {"scope": "project", "projectPath": "<session cwd>"} entry. Existing "scope": "user" entries are clobbered rather than merged/appended. Net effect on a machine where many repos enable the same plugins: whichever repo last ran a plugin-using session "owns" them, and sessions in every other repo fail to load them (Unknown skill: <name>, agent type '<plugin>:<agent>' not found) — while claude plugin list still shows them as installed + enabled from any cwd.

Repro (observed, deterministic on the affected machine):

  1. claude plugin install codex@skills --scope user → registry entry is {"scope": "user"}. ✅
  2. In repo A (whose .claude/settings.json has "enabledPlugins": {"codex@skills": true, ...}), run a session that invokes the plugin's skill, e.g. claude -p --permission-mode bypassPermissions "invoke the codex skill ...".
  3. Registry entry for codex@skills — and for all other plugins repo A enables (six in our case, including ones the session never used) — now reads {"scope": "project", "projectPath": "/path/to/repoA"}. The user-scope entries are gone.
  4. Sessions in repo B (which enables the same plugins) no longer load them. Repeat step 2 from repo B and ownership flips to B.

Additional observations:

  • Trivial sessions (claude -p "say hi", no tool use) do not trigger the rewrite; plugin-using sessions do. Consistent with the docs note that as of v2.1.195 the install/registration step "applies on every path that loads plugins" (discover-plugins §7) — same defective write, implicit trigger.
  • Unlike the OP's full-file overwrite, unrelated plugins' records were not wiped in our case: a 25-entry registry stayed intact; only the entries for the active project's enabled plugins were rewritten/re-scoped. So the pass appears to walk the project's enabled plugins and write them non-merging into the map, replacing each plugin's array rather than regenerating the whole file.
  • A second machine on identical 2.1.207 (same cached feature flags) does not reproduce: user-scope entries persist there and arrays append correctly (we observe coexisting project + user entries for the same plugin), so the clobber may depend on registry state/timing when the implicit registration pass decides to write.
  • There's currently no way to defend against this in config: #62209 (pluginDefaultScope) was closed stale, and autoUpdate: false in known_marketplaces.json is re-asserted from settings extraKnownMarketplaces on the next load. We're running an external watcher that restores the user-scope entries after each clobber, which is not a workaround that should be necessary.

Related: #14202, #62209, and the v2.1.108 changelog entry ("Fixed policy-managed plugins never auto-updating when running from a different project than where they were first installed"), which made this reconciliation run from any project.

felipepenha · 1 month ago

Issue remains in version 2.1.206.

felipepenha · 1 month ago

@yasyf Bug has been demonstrated with the cli, but it is also reproducible via the claude-code interactive session.

Each new session will overwrite a previous installed_plugins.json with the newly installed plugins in that session.

It is seems that the current logic assumes that the user will install every needed plugin at once, in a single session, and that there is a single project in the system, which is absurd. This cannot possibly be what developers had in mind.

Leveraging custom marketplaces via plugins continue unusable at this point.

felipepenha · 1 month ago

I am seeing the same problem at the user level.

While trying to circumvent the issue in the project scope, I noticed that the user level installation is also overwriting the json config file in the system.

This overall behavior is a critical blocker for marketplace users.

felipepenha · 1 month ago

Since the claude-code CLI is proprietary (closed-source), direct PR contributions to the CLI itself are not possible. I am sharing an analysis of the observed behavior and a suggested TypeScript implementation to help the Anthropic team resolve the issue.

Root Cause Analysis (inferred from observed behavior)

In claude-code >= 2.1.195, both the CLI installer and the implicit plugin-loading path register active plugins in the registry file ~/.claude/plugins/installed_plugins.json.

The registry maps each plugin ID (e.g. codex@skills) to an array of installation records. The array exists because the same plugin can legitimately be active in multiple scopes at once — at the user level globally, and at the project level for one or more project paths.

The observed behavior is consistent with a registry write that replaces instead of merging — either replacing the per-plugin array with a single-element array (registry.plugins[pluginId] = [newRecord]), or rebuilding the entire registry from only the plugins known to the current operation. Since the CLI is closed-source this is an inference, but it explains both reported failure modes:

  1. Project scope, explicit install (this issue): sequential claude plugin install --scope project runs wipe previously installed entries — only the last-installed plugin survives.
  2. User scope, implicit session-time registration (@yasyf's report): a session that merely uses a plugin rewrites its registry entry as a single {"scope": "project", "projectPath": "<cwd>"} record, clobbering the existing "scope": "user" record — after which every other project fails to load the plugin (Unknown skill: <name>), while claude plugin list still claims it is installed.

Requirements for a Correct Fix

A correct fix must satisfy all four of these, not just the first:

  1. Merge, don't overwrite: read the existing registry and preserve all entries for other plugin IDs.
  2. Scope-aware record replacement within a plugin's array: a new user-scope record replaces only the previous user record; a new project-scope record replaces only the record with the same projectPath. Records for other scopes/projects are appended-to, never dropped, so this state is valid and stable:

``json
"codex@skills": [
{ "scope": "user", ... },
{ "scope": "project", "projectPath": "/path/to/repoA", ... },
{ "scope": "project", "projectPath": "/path/to/repoB", ... }
]
``

  1. Never destroy data on read failure: if the registry file is unreadable or corrupt, it must be backed up (e.g. renamed to installed_plugins.json.corrupt-<timestamp>) — not silently replaced with an empty registry. A fallback that discards the file reintroduces this very bug through a different door.
  2. Serialize concurrent writers: since the write fires implicitly during normal sessions (per @yasyf), two concurrent sessions can race on the read-modify-write cycle and the last writer silently drops the other's entry. An atomic temp-file rename alone prevents torn files but not lost updates — a lock around the whole read-modify-write is required.

Suggested TypeScript Implementation

import * as fs from 'fs-extra';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';

interface InstallationRecord {
  scope: 'user' | 'project';
  installPath: string;
  version: string;
  installedAt: string;
  lastUpdated: string;
  gitCommitSha?: string;
  projectPath?: string; // present when scope === 'project'
}

interface Registry {
  version: number;
  plugins: Record<string, InstallationRecord[]>;
}

async function readRegistry(registryPath: string): Promise<Registry> {
  if (!(await fs.pathExists(registryPath))) {
    return { version: 2, plugins: {} };
  }
  try {
    const data = await fs.readJson(registryPath);
    // Guard against non-object and array-shaped JSON: typeof [] === 'object',
    // and properties set on an array are dropped by JSON.stringify on write-back.
    if (data && typeof data === 'object' && !Array.isArray(data)) {
      const registry = data as Registry;
      if (!registry.plugins || typeof registry.plugins !== 'object' || Array.isArray(registry.plugins)) {
        registry.plugins = {};
      }
      registry.version = 2;
      return registry;
    }
  } catch {
    // fall through to the backup path below
  }
  // Never silently discard a registry we could not parse — keep it for
  // recovery, then start from a clean slate.
  await fs
    .move(registryPath, `${registryPath}.corrupt-${Date.now()}`, { overwrite: false })
    .catch(() => {});
  return { version: 2, plugins: {} };
}

async function registerPlugin(
  registryPath: string,
  pluginId: string,
  record: InstallationRecord
): Promise<void> {
  const dir = path.dirname(registryPath);
  await fs.ensureDir(dir);

  // Serialize concurrent read-modify-write cycles: explicit installs and
  // implicit session-time registration can run at the same time, and without
  // a lock the last writer silently drops the other's entry.
  const release = await lockfile.lock(dir, {
    retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },
  });

  try {
    const registry = await readRegistry(registryPath);

    // Defend against legacy single-object (non-array) entries.
    const existing = registry.plugins[pluginId];
    const existingRecords: InstallationRecord[] = Array.isArray(existing)
      ? existing
      : existing && typeof existing === 'object'
      ? [existing as InstallationRecord]
      : [];

    // Replace only the record that shares the new record's identity:
    // - user scope is global → replace the previous user record
    // - project scope is keyed by projectPath → replace only the same path
    const updatedRecords = existingRecords.filter((r) => {
      if (record.scope === 'user') {
        return r.scope !== 'user';
      }
      return !(r.scope === 'project' && r.projectPath === record.projectPath);
    });

    updatedRecords.push(record);
    registry.plugins[pluginId] = updatedRecords;

    // Atomic replace: prevents torn/partial files if the process dies
    // mid-write. (Lost-update protection comes from the lock above, not
    // from this rename.)
    const tempPath = `${registryPath}.${process.pid}.tmp`;
    await fs.writeJson(tempPath, registry, { spaces: 2 });
    await fs.move(tempPath, registryPath, { overwrite: true });
  } finally {
    await release();
  }
}

The same merge discipline must be applied at every write site of installed_plugins.json — the explicit claude plugin install path, the implicit session-time registration/reconciliation path, and (with inverted logic: remove one record, keep the rest) the uninstall path. Fixing only the installer would leave @yasyf's implicit-clobber path intact.

felipepenha · 1 month ago

Also submitted via /feedback in Claude Code for internal correlation:

Feedback ID 94fd108d-092a-4a0a-824b-fd4c67929755

Showing cached comments. Read the full discussion on GitHub ↗