[BUG] /plugin marketplace add fails on macOS when repo owner case ≠ marketplace.json name (unfixed since #23205)
Summary
/plugin marketplace add <Owner>/<repo> fails on macOS (default case-insensitive APFS) whenever the marketplace repo declares a name in marketplace.json that differs from the GitHub owner's capitalization. Same mechanism as #23205 (closed by stale bot, no fix shipped, now locked — the bot's closing message asks to file a new issue).
Claude Code version: 2.1.119
OS: macOS (APFS, case-insensitive, case-preserving — the default)
Reproducer
/plugin marketplace add AgriciDaniel/claude-ads
Repo's .claude-plugin/marketplace.json declares:
{ "name": "agricidaniel-claude-ads", "owner": { "name": "AgriciDaniel" }, ... }
Error:
Error: Failed to finalize marketplace cache. Please manually delete the directory at
/Users/moi/.claude/plugins/marketplaces/agricidaniel-claude-ads if it exists and try again.
Technical details: ENOENT: no such file or directory,
rename '/Users/moi/.claude/plugins/marketplaces/AgriciDaniel-claude-ads'
-> '/Users/moi/.claude/plugins/marketplaces/agricidaniel-claude-ads'
The error message is misleading: there is nothing to delete — Claude Code wipes the directory itself during the failing flow. Every retry re-clones → self-wipes → re-fails.
Root cause
Traced the code path in the shipped claude binary. The marketplace installer (zM8) uses SV5() to compute the clone directory slug:
function SV5(H) {
return (H.source === "github"
? H.repo.replaceAll("/","-") // "AgriciDaniel/claude-ads" -> "AgriciDaniel-claude-ads"
: ...
).replace(/[^a-zA-Z0-9\-_]/g, "-");
}
Slug preserves the owner's casing. Flow that follows:
let A = SV5(H);
let O = path.join(K, A); // /…/marketplaces/AgriciDaniel-claude-ads — clone destination
// (github clone happens here, into O)
// Read marketplace.json from O, get z.name = "agricidaniel-claude-ads"
let Y = path.join(K, z.name); // /…/marketplaces/agricidaniel-claude-ads — final canonical path
if (O !== Y && !dC(H)) { // string compare — true here
try {
await q.rm(Y, {recursive:true, force:true}); // <-- SELF-WIPE on case-insensitive FS
await q.rename(O, Y); // <-- ENOENT: O was just wiped
} catch (j) {
throw Error(`Failed to finalize marketplace cache. Please manually delete ...`);
}
}
On case-insensitive APFS, AgriciDaniel-claude-ads and agricidaniel-claude-ads resolve to the same inode. fs.rm(Y, …) therefore deletes the clone that was just created. The subsequent fs.rename(O, Y) fails with ENOENT.
The O !== Y gate only compares the path strings, so it does not detect the case-folding collision. Same bug class applies to case-insensitive NTFS on Windows.
Workarounds
User-side (simplest — if you haven't run the broken add yet)
Type the owner in lowercase:
/plugin marketplace add agricidaniel/claude-ads
SV5() then produces agricidaniel-claude-ads, which matches z.name, so the rename branch is skipped entirely. First spotted in a comment on #23205.
Recovery (if you already hit the broken path and want the upstream repo casing)
Clone manually and register directly:
git clone --depth=1 https://github.com/AgriciDaniel/claude-ads.git \
~/.claude/plugins/marketplaces/agricidaniel-claude-ads
Then add an entry to ~/.claude/plugins/known_marketplaces.json:
"agricidaniel-claude-ads": {
"source": { "source": "github", "repo": "AgriciDaniel/claude-ads" },
"installLocation": "/Users/<you>/.claude/plugins/marketplaces/agricidaniel-claude-ads",
"lastUpdated": "2026-04-24T00:00:00.000Z"
}
Restart the CC session — the "already materialized" short-circuit in the installer (for (let [Y,w] of Object.entries(K)) if (zY(w.source,q)) return …alreadyMaterialized:true) then skips the broken clone path on subsequent operations. /plugin marketplace update works fine afterwards because it git pulls the existing clone and does not re-enter the rename branch.
Proposed fixes
Either would close this (both is better):
- Lowercase the clone slug at construction. In
SV5(), chain.toLowerCase()on the github/npm/file/directory branches. This makesOandz.nameagree from the start and sidesteps the rename on every platform.
- Case-insensitive-safe
O === Yguard. Beforerm + rename, resolve both paths viafs.realpathSync(or comparefs.statSync(...).ino) and skip the branch if they collapse to the same filesystem entity. This also defends against future slug-construction changes.
Related
- #23205 — same bug, reported Feb 2026, closed
NOT_PLANNEDby stale-bot, now locked. Closing bot explicitly asked reporters to file a new issue and reference the old one. - #14799 —
EXDEVon tmpfs during plugin install (different error, same "move dir across states" code area).
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗