plugin marketplace update fetches new version but doesn't update installed_plugins.json

Status Fixed / completed
Maintainer reply None cached
Activity 4 comments · opened Jul 12, 2026 · closed Aug 17, 2026

Bug

claude plugin marketplace update <plugin> downloads the new plugin version into the cache directory but does not update the installPath and version fields in ~/.claude/plugins/installed_plugins.json. New sessions continue using the old version.

Reproduction

  1. Publish a new version of a plugin (bump version in plugin.json, push to git)
  2. Run claude plugin marketplace update <plugin-name>
  3. Start a new Claude session

Expected: New skills/features from the updated plugin are available.
Actual: Session loads the old version. New skills missing.

Root cause

After update, the cache has both versions:

~/.claude/plugins/cache/bat-skills/bat-skills/1.20.0/skills/  # old — 7 skills
~/.claude/plugins/cache/bat-skills/bat-skills/1.21.0/skills/  # new — 10 skills (3 added)

But installed_plugins.json still points to the old version:

"bat-skills@bat-skills": [
  {
    "installPath": "…/cache/bat-skills/bat-skills/1.20.0",
    "version": "1.20.0",
    …
  }
]

Workaround

Manually edit ~/.claude/plugins/installed_plugins.json — update installPath and version to the new version directory.

Environment

  • Claude Code CLI (latest as of 2026-07-12)
  • Linux (Fedora 44)
  • Plugin: custom marketplace plugin with plugin.json versioning

View original on GitHub ↗

3 Comments

jfenal · 1 month ago

Additional finding: cache directory not created for latest version

After running claude plugin marketplace update, the cache directory for the new version was never created either.

State after update:

~/.claude/plugins/cache/<plugin>/<plugin>/1.20.0/   # old
~/.claude/plugins/cache/<plugin>/<plugin>/1.20.1/   # old
~/.claude/plugins/cache/<plugin>/<plugin>/1.21.0/   # previous
# 1.22.0 — MISSING (never fetched)

Meanwhile installed_plugins.json was updated to point to 1.22.0 (via manual fix from original bug), but the directory doesn't exist.

So two bugs in the update flow:

  1. (Original report) installed_plugins.json version pointer not updated after fetch
  2. (New) The latest version isn't always fetched into the cache directory at all

Workaround: Manually copy the plugin repo into the cache:

cp -r /path/to/plugin/repo ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>
jfenal · 21 days ago

Workaround script

Wrote a script that detects and fixes both issues described above by cross-checking each installed plugin against its marketplace repo clone (~/.claude/plugins/marketplaces/<marketplace>/):

  • if the marketplace's current version isn't in cache/, copies it there (fixes the "never fetched" issue)
  • if installed_plugins.json points at a stale version/path, repoints it at the correct cache dir (fixes the original report)

Dry-run by default, backs up installed_plugins.json before writing with --apply. Skips externally-sourced plugins (git-subdir/url sources, fetched via a different mechanism) and unversioned plugins (can't reliably reconstruct their cache dir name).

Confirmed it catches this live — running claude plugin marketplace update on my machine silently left understand-anything on 2.6.3 while the marketplace had moved to 2.9.4, with no 2.9.4 directory ever created under cache/. Script fetched it and repointed the entry correctly.

#!/usr/bin/env python3
"""
Workaround for anthropics/claude-code#76882: `claude plugin marketplace update`
can leave installed_plugins.json pointing at a stale version, or skip fetching
the new version into cache/ entirely.

This script cross-checks each installed plugin against its marketplace repo
(the git clone under ~/.claude/plugins/marketplaces/<marketplace>/), and:
  - if the marketplace's current version isn't in cache/, copies it there
  - if installed_plugins.json points at a different version/missing path,
    repoints it at the correct cache dir

Dry-run by default. Pass --apply to actually write changes.
"""
import argparse
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path

PLUGINS_DIR = Path.home() / ".claude" / "plugins"
INSTALLED_JSON = PLUGINS_DIR / "installed_plugins.json"
KNOWN_MARKETPLACES_JSON = PLUGINS_DIR / "known_marketplaces.json"
CACHE_DIR = PLUGINS_DIR / "cache"
MARKETPLACES_DIR = PLUGINS_DIR / "marketplaces"


def load_json(path):
    with open(path) as f:
        return json.load(f)


def marketplace_plugin_versions(marketplace_key):
    """Read <marketplace repo>/.claude-plugin/marketplace.json and resolve
    each plugin's current version, falling back to its own plugin.json."""
    repo_dir = MARKETPLACES_DIR / marketplace_key
    mp_json_path = repo_dir / ".claude-plugin" / "marketplace.json"
    if not mp_json_path.exists():
        return {}

    mp = load_json(mp_json_path)
    versions = {}
    for entry in mp.get("plugins", []):
        name = entry["name"]
        source_rel = entry.get("source", "./")
        if not isinstance(source_rel, str):
            # External plugin (git-subdir/url source) — fetched into
            # external_plugins/ by its own mechanism, not handled here.
            continue
        source_dir = (repo_dir / source_rel).resolve()
        version = entry.get("version")
        if not version:
            plugin_json_path = source_dir / ".claude-plugin" / "plugin.json"
            if plugin_json_path.exists():
                version = load_json(plugin_json_path).get("version")
        # `version` stays None for genuinely unversioned plugins — Claude
        # Code names their cache dir inconsistently ("unknown" or a git
        # short-sha), so we can't reconstruct the expected path and only
        # check that *some* installPath exists for these.
        versions[name] = (version, source_dir)
    return versions


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--apply", action="store_true",
                         help="Actually fix drift (default: report only)")
    args = parser.parse_args()

    installed = load_json(INSTALLED_JSON)
    known = load_json(KNOWN_MARKETPLACES_JSON)

    fixes = []
    for plugin_key, install_entries in installed.get("plugins", {}).items():
        if "@" not in plugin_key:
            continue
        plugin_name, marketplace_key = plugin_key.rsplit("@", 1)
        if marketplace_key not in known:
            continue

        mp_versions = marketplace_plugin_versions(marketplace_key)
        if plugin_name not in mp_versions:
            continue
        current_version, source_dir = mp_versions[plugin_name]

        for entry in install_entries:
            install_path = Path(entry["installPath"])
            recorded_version = entry.get("version")

            if current_version is None:
                # Unversioned plugin: can't compute an expected cache path,
                # just flag it if the recorded one has gone missing.
                if not install_path.exists():
                    fixes.append({
                        "plugin": plugin_key,
                        "scope": entry.get("scope"),
                        "recorded_version": recorded_version,
                        "current_version": "(unversioned)",
                        "old_install_path": str(install_path),
                        "new_install_path": None,
                        "needs_fetch": False,
                        "unresolvable": True,
                        "source_dir": str(source_dir),
                        "entry": entry,
                    })
                continue

            expected_cache_dir = CACHE_DIR / marketplace_key / plugin_name / current_version

            needs_fetch = not expected_cache_dir.exists()
            needs_repoint = (
                recorded_version != current_version
                or not install_path.exists()
                or install_path != expected_cache_dir
            )

            if not needs_fetch and not needs_repoint:
                continue

            fixes.append({
                "plugin": plugin_key,
                "scope": entry.get("scope"),
                "recorded_version": recorded_version,
                "current_version": current_version,
                "old_install_path": str(install_path),
                "new_install_path": str(expected_cache_dir),
                "needs_fetch": needs_fetch,
                "unresolvable": False,
                "source_dir": str(source_dir),
                "entry": entry,
            })

    if not fixes:
        print("No drift detected — all installed plugins match their marketplace version.")
        return

    fixable = [fx for fx in fixes if not fx["unresolvable"]]
    unresolvable = [fx for fx in fixes if fx["unresolvable"]]

    print(f"Found {len(fixes)} drifted plugin install(s):\n")
    for fx in fixes:
        print(f"  {fx['plugin']} (scope={fx['scope']})")
        print(f"    recorded version: {fx['recorded_version']!r} -> current: {fx['current_version']!r}")
        if fx["unresolvable"]:
            print(f"    installPath MISSING: {fx['old_install_path']}")
            print("    unversioned plugin — can't auto-resolve, reinstall manually")
        else:
            if fx["needs_fetch"]:
                print(f"    cache MISSING for {fx['current_version']} — would copy from {fx['source_dir']}")
            print(f"    installPath: {fx['old_install_path']} -> {fx['new_install_path']}")
        print()

    if not fixable:
        return

    if not args.apply:
        print("Dry run only. Re-run with --apply to fix.")
        return

    backup_path = INSTALLED_JSON.with_suffix(".json.driftfix.bak")
    shutil.copy2(INSTALLED_JSON, backup_path)
    print(f"Backed up installed_plugins.json to {backup_path}")

    now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

    for fx in fixable:
        new_path = Path(fx["new_install_path"])
        if fx["needs_fetch"]:
            source_dir = Path(fx["source_dir"])
            new_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copytree(
                source_dir, new_path,
                ignore=shutil.ignore_patterns(".git"),
                dirs_exist_ok=True,
            )
            print(f"Fetched {fx['plugin']} {fx['current_version']} into {new_path}")

        fx["entry"]["installPath"] = str(new_path)
        fx["entry"]["version"] = fx["current_version"]
        fx["entry"]["lastUpdated"] = now

    with open(INSTALLED_JSON, "w") as f:
        json.dump(installed, f, indent=2)
        f.write("\n")

    print(f"\nFixed {len(fixable)} entr{'y' if len(fixable) == 1 else 'ies'} in {INSTALLED_JSON}")
    if unresolvable:
        print(f"{len(unresolvable)} unresolvable entr{'y' if len(unresolvable) == 1 else 'ies'} left untouched (see above).")
    print("Start a new Claude Code session to pick up the changes.")


if __name__ == "__main__":
    sys.exit(main())
deruelle · 17 days ago

Some measured evidence on this from a repo that distributes a plugin through a marketplace entry. CLI 2.1.228, Linux. Everything below was taken in a scratch HOME with all Claude credential variables unset.

Identity lands in two independent fields, with undocumented and non-exclusive population rules, and only one of them is read by the update comparator.

Two installs of the same plugin, on the same day, at different delivered commits:

version       = 43c7d3d79542-31fddb37
gitCommitSha  = 43c7d3d79542e0909b3825ec17a3d58e193524de

version       = 0d6443960662-31fddb37
gitCommitSha  = 0d644396066262b32884a2faec10e317857bea5e

The version string is a compound, and only its leading half varies with content. The 12-character half equals the delivered commit both times (confirmed against git ls-remote … HEAD). The 8-character half is byte-identical across two different delivered commits, so it does not identify the content it appears to identify. What it encodes is not established here and I am not guessing.

Separately, a pre-existing install on the same machine carried version: 0.0.0-dev and a valid 40-character gitCommitSha at the same time. So the two fields are not alternatives: a consumer cannot infer from the presence of one that the other is absent or stale.

Why that bears on this issue. claude plugin update compares version strings. An entry whose recorded version is constant compares equal on every run, so the update short-circuits and reports success having delivered nothing — with no error and no visible symptom — while a correct gitCommitSha sits beside it in the same record showing the install is stale. That is the shape this issue describes, and the metadata is what makes it invisible.

One more property, in case it saves someone a debugging cycle: claude plugin list --json is a projection of installed_plugins.json, not an independent authority. Mutating the file to a sentinel version and installPath changed the CLI's output verbatim, and restoring the file restored the output. So reading the CLI does not let a check escape the metadata — worth knowing if you are tempted to verify an install by asking the CLI rather than by comparing delivered bytes.

Happy to provide the raw readings or the reproduction steps if useful.

Showing cached comments. Read the full discussion on GitHub ↗