[BUG] Marketplace-entry dependencies: version constraints silently ignored, prune deletes live dependencies
What's Wrong?
A dependency declared in a marketplace entry is honored by the installer's name resolution and by nothing else. The docs state both declaration sites are valid:
A plugin can depend on other plugins by listing them in plugin.json or in its marketplace entry. — Constrain plugin dependency versions, opening sentence
The dependency does get auto-installed. But its version constraint is dropped, and every consumer of the dependency graph is blind to the edge.
Worst symptom: a version constraint declared in a marketplace entry is silently ignored. ~2.1.0 installs 3.0.0, with no error anywhere.
Every row below was measured against a control that differs only in which of the two supported sites the dependency is declared in. Claude Code 2.1.237.
| Surface | Declared in marketplace entry | Declared in plugin.json (control) |
|---|---|---|
| Install-time tag resolution of ~2.1.0 | installs 3.0.0 | installs 2.1.5 |
| range-conflict between ~2.1.0 and ~3.0.0 | both install, no error | install refused, names both ranges |
| dependency-version-unsatisfied at load | no errors field | Requires "plugin-b@…" ~2.1.0, installed 1.0.0 |
| uninstall <unrelated> --prune -y | deletes the live dependency | Nothing to prune … all still needed |
| disable <dependency> | succeeds silently | refused, with a chained fix command |
| enable <dependent> | dependency stays disabled | (also enabled 1 dependency: plugin-b) |
| install <dependent> to restore a missing dep | no re-resolution | already installed (+ 1 dependency: plugin-b) |
| list --json on a broken dependent | no errors field, "enabled": true | error reported, plugin disabled |
Cross-marketplace allowlist enforcement is the one thing that does work for a catalog-declared dependency — it correctly blocks a foreign dependency. So the entry's dependency objects are parsed and the marketplace field is read; only version is dropped and only the graph consumers are blind.
What Should Happen?
Every consumer of the dependency graph should resolve it from the same sources the installer does — the plugin manifest and the marketplace entry — so that where a dependency is declared never changes what it means.
Steps to Reproduce
A. Version constraint ignored. Needs a git-backed marketplace, since constraints resolve against tags.
R=/tmp/mkt; mkdir -p $R/.claude-plugin $R/plugin-a/.claude-plugin $R/plugin-b/.claude-plugin
cat > $R/.claude-plugin/marketplace.json <<'EOF'
{ "name": "catmkt", "owner": { "name": "r" },
"plugins": [
{ "name": "plugin-a", "source": "./plugin-a",
"dependencies": [{ "name": "plugin-b", "version": "~2.1.0" }] },
{ "name": "plugin-b", "source": "./plugin-b" } ] }
EOF
echo '{ "name": "plugin-a", "version": "1.0.0", "description": "dependent" }' > $R/plugin-a/.claude-plugin/plugin.json
cd $R && git init -q .
for v in 1.0.0 2.1.5 3.0.0; do
echo "{ \"name\": \"plugin-b\", \"version\": \"$v\", \"description\": \"dep\" }" > $R/plugin-b/.claude-plugin/plugin.json
git add -A && git commit -qm "b $v" && git tag "plugin-b--v$v"
done
export CLAUDE_CONFIG_DIR=/tmp/cfg
claude plugin marketplace add $R
claude plugin install plugin-a@catmkt --scope user
claude plugin list --json # plugin-b is 3.0.0, no errors
B. Prune deletes a live dependency. Three files, no network, no tags.
R=/tmp/mkt2; mkdir -p $R/.claude-plugin $R/plugin-{a,b,c}/.claude-plugin
cat > $R/.claude-plugin/marketplace.json <<'EOF'
{ "name": "repro", "owner": { "name": "r" },
"plugins": [
{ "name": "plugin-a", "source": "./plugin-a", "dependencies": ["plugin-b"] },
{ "name": "plugin-b", "source": "./plugin-b" },
{ "name": "plugin-c", "source": "./plugin-c" } ] }
EOF
echo '{ "name": "plugin-a", "version": "1.0.0", "description": "dependent" }' > $R/plugin-a/.claude-plugin/plugin.json
echo '{ "name": "plugin-b", "version": "1.0.0", "description": "dependency" }' > $R/plugin-b/.claude-plugin/plugin.json
echo '{ "name": "plugin-c", "version": "1.0.0", "description": "unrelated" }' > $R/plugin-c/.claude-plugin/plugin.json
export CLAUDE_CONFIG_DIR=/tmp/cfg2
claude plugin marketplace add $R
claude plugin install plugin-a@repro --scope user # auto-installs plugin-b
claude plugin install plugin-c@repro --scope user
claude plugin uninstall plugin-c@repro --prune -y # deletes plugin-b
claude plugin install plugin-a@repro --scope user # documented recovery — does nothing
Control for both: move dependencies out of the marketplace entry into plugin-a's plugin.json, change nothing else.
Error Messages/Logs
A — constraint ~2.1.0, tags 1.0.0 / 2.1.5 / 3.0.0:
### declared in the marketplace entry
✔ Successfully installed plugin: plugin-a@catmkt (scope: user) (+ 1 dependency: plugin-b)
plugin-a@catmkt version= 1.0.0 errors= None
plugin-b@catmkt version= 3.0.0 errors= None <-- ~2.1.0 resolved to 3.0.0
### declared in plugin.json (control)
✔ Successfully installed plugin: plugin-a@manmkt (scope: user) (+ 1 dependency: plugin-b)
plugin-b@manmkt version= 2.1.5-3f65da1c257f errors= None
B — prune, and the failed recovery:
$ claude plugin uninstall plugin-c@repro --prune -y
✔ Successfully uninstalled plugin: plugin-c (scope: user)
Removed 1 auto-installed plugin: plugin-b <-- plugin-a still installed and still requires it
$ claude plugin install plugin-a@repro --scope user
✔ Plugin "plugin-a@repro" is already installed (scope: user) <-- no dependency clause
plugin-a@repro errors= None <-- still broken, still silent
Control, same fixture, dependency moved into plugin.json:
$ claude plugin uninstall plugin-c@repro --prune -y
Nothing to prune (1 auto-installed plugin at user scope, all still needed).
$ claude plugin disable plugin-b@repro
✘ Failed to disable plugin "plugin-b@repro": plugin-b is still required by plugin-a. Disable that plugin first, or disable everything together: claude plugin disable plugin-a@repro && claude plugin disable plugin-b@repro
$ claude plugin enable plugin-a@repro
✔ Successfully enabled plugin: plugin-a (scope: user) (also enabled 1 dependency: plugin-b)
Additional Information
Why we declare dependencies in the catalog
We run an internal marketplace of a few dozen plugins that all depend on one shared foundation plugin. We declare that edge once in the generated catalog instead of hand-copying it into every plugin's plugin.json — which the docs support, and which the marketplace reference reinforces: a plugin entry "can include any field from the [plugin manifest schema]". dependencies is a plugin manifest field.
Two consequences on our users' machines: any version we pin in the catalog is ignored, and a routine uninstall … --prune removes the foundation plugin that everything else needs. Nothing reports an error afterward, and the documented recovery (claude plugin install <dependent>) doesn't restore it — so it surfaces later as "a skill stopped working" with nothing pointing at the cause.
Not data loss: reinstalling the dependency by name fixes it. The problem is that nothing tells you that's what happened.
Workaround
Declare dependencies in both the marketplace entry and the plugin's plugin.json. Verified to restore correct prune behavior.
Two observations that may help locate it
- The reverse graph appears to resolve from the live marketplace-side plugin definition, not the install-time cache. Editing a cached
plugin.jsonto drop a dependency did not changeprune's verdict; editing the marketplace's source copy changed it immediately. So the resolver already reads the marketplace side — it just doesn't pick up the entry's owndependencies. - We're deliberately not proposing a mechanism. Merge semantics when both sites declare dependencies (presumably union, with two constraints on one name feeding the existing intersection logic) are your call. Worth flagging either way: once entry-declared constraints are honored, setups that install silently today will correctly start reporting
range-conflictordependency-version-unsatisfied.
One inconsistency noticed along the way: prune already fails closed when a plugin's source can't be loaded (Skipped — cannot determine orphans: … failed to load), but disable does not — it still succeeded with the marketplace directory deleted.
Not measured
/reload-plugins and background auto-update both walk the dependency graph per the docs and are likely affected by the same root cause, but we drove neither and aren't claiming them.
Related, not duplicate
#77801, #68449 — both install-side. Searched open and closed; nothing covers the reverse graph or the declaration site.
Environment
- Claude Code 2.1.237 (current
lateston npm at filing;stableis 2.1.228) - macOS 15 / Darwin 24.6.0, every result from a fresh
CLAUDE_CONFIG_DIR. The prune symptom was independently reported by a colleague on Windows 11 Pro 10.0.26100. - Reproduced with local-folder and local-git marketplaces; also observed on our git-hosted internal marketplace.