[BUG] /plugin marketplace add fails on macOS when repo owner case ≠ marketplace.json name (unfixed since #23205)

Resolved 💬 3 comments Opened Apr 24, 2026 by lazyants Closed Apr 28, 2026

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):

  1. Lowercase the clone slug at construction. In SV5(), chain .toLowerCase() on the github/npm/file/directory branches. This makes O and z.name agree from the start and sidesteps the rename on every platform.
  1. Case-insensitive-safe O === Y guard. Before rm + rename, resolve both paths via fs.realpathSync (or compare fs.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_PLANNED by stale-bot, now locked. Closing bot explicitly asked reporters to file a new issue and reference the old one.
  • #14799 — EXDEV on tmpfs during plugin install (different error, same "move dir across states" code area).

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗