Claude code plugin bug
# Article materials: the Claude Code marketplace deletion bug
**Prepared**: 2026-04-06
**Source investigation**: `/Users/jack/mag/magus/ai-docs/plugin-marketplace-bug-investigation.md`
**Claude Code version**: 2.1.92 (source at `claude-code/src/utils/plugins/`)
---
## 1. HOOK -- Opening angles (3 options)
### Angle A -- The mystery
Open with the error message:
```
Stop hook error: Failed to run: Plugin directory does not exist:
/Users/jack/.claude/plugins/marketplaces/magus/plugins/dev
(dev@magus -- run /plugin to reinstall)
```
The message tells you to run `/plugin` to reinstall. You do. Nothing changes. You restart Claude Code. Same error. You uninstall the plugin and reinstall it. Same error. You clear the plugin cache. Same error. You try five more things across multiple sessions over multiple days.
The error says the problem is a missing plugin directory. The actual problem is 400 lines away in a different file -- a function that deletes an entire marketplace directory before attempting to re-clone it, then swallows the error when the clone fails.
This angle works for: developers who have hit this bug and want to understand *why*. The payoff is the moment the investigation shifts from "what's broken" to "what caused the breakage."
### Angle B -- The asymmetry
Anthropic ships Claude Code with its own plugin marketplace: `claude-plugins-official`. Those plugins never break. Not once. You can kill your network, corrupt your git config, restart a hundred times -- the official plugins keep working.
Third-party marketplaces break constantly. Eight open GitHub issues. Users on macOS and Linux. Workarounds that get overwritten every time Claude Code starts a new session. One user reports patching hooks.json by hand, only to have Claude Code rewrite it seconds later.
The asymmetry is architectural. The official marketplace downloads from Google Cloud Storage as an atomic tarball. Third-party marketplaces go through a git code path that deletes the directory before cloning. The official marketplace is structurally immune to the bug that afflicts every third-party marketplace.
This angle works for: readers interested in platform dynamics, the relationship between first-party and third-party developers, and architectural decisions that create unequal outcomes.
### Angle C -- The source code detective story
Open at the moment we read `marketplaceManager.ts` line 1131:
```typescript
await fs.rm(cachePath, { recursive: true })
```
One line. Deletes the entire marketplace directory -- every plugin, every hook, every skill file. The function tries to `git pull` first, and if the pull fails for any reason (network timeout, auth error, merge conflict), it deletes everything and attempts a fresh clone. If the clone also fails, the error gets caught, logged at `warn` level, and swallowed. The directory stays gone.
This angle works for: technical readers who want to see the actual code. The narrative follows the investigation through source files: `marketplaceManager.ts` -> `pluginAutoupdate.ts` -> `hooks.ts` -> `pluginLoader.ts`. Each file reveals another piece of the puzzle.
---
## 2. KEY CHARACTERS (for narrative)
**Jack** -- Plugin marketplace developer. Maintains Magus, a third-party Claude Code marketplace with 16 plugins and 4,008 files. Has been fighting this bug across 5-6 sessions. Built the workaround.
**Claude Code** -- Anthropic's CLI tool for AI-assisted development. Version 2.1.92. Ships with a plugin ecosystem that supports third-party marketplaces installed via git clone.
**Magus marketplace** -- Third-party marketplace. 16 plugins, 4,008 files, active development. Has `autoUpdate: true` in its marketplace configuration, meaning Claude Code refreshes it on every session start.
**claude-plugins-official** -- Anthropic's own marketplace. NOT a git repository -- no `.git` directory exists. Fetched from Google Cloud Storage as a tarball. Structurally immune to the delete-then-clone bug.
**The hook executor** -- `hooks.ts`, line 831. The gatekeeper. Before running any plugin hook, it checks `fs.exists(pluginRoot)`. When the marketplace directory is gone, every hook for every plugin in that marketplace throws.
**cacheMarketplaceFromGit()** -- `marketplaceManager.ts`, line 1084. The function with the bug. Pulls, deletes, clones -- in that order, with no atomicity.
**pluginAutoupdate.ts** -- The caller that swallows the error. Line 250. Catches the thrown error, logs it at `warn` level, and continues. No recovery. No retry. No notification to the user.
**installed_plugins.json** -- The registry that knows where every plugin's cache copy lives. Has the correct `installPath` for every installed plugin. The hook executor does not consult it.
**The typeof branch** -- `pluginLoader.ts`, line 2108. The pivot point. `typeof entry.source === 'string'` sends plugins down the vulnerable path (reads from marketplace clone). `typeof entry.source === 'object'` sends them down the safe path (reads from versioned cache).
---
## 3. CODE SNIPPETS (ready to paste)
### Snippet 1 -- The bug (delete before clone)
**File**: `claude-code/src/utils/plugins/marketplaceManager.ts`, lines 1084-1178
```typescript
async function cacheMarketplaceFromGit(
gitUrl: string,
cachePath: string,
ref?: string,
sparsePaths?: string[],
onProgress?: MarketplaceProgressCallback,
options?: { disableCredentialHelper?: boolean },
): Promise<void> {
const fs = getFsImplementation()
// ...setup...
const reconcileResult = await reconcileSparseCheckout(cachePath, sparsePaths)
if (reconcileResult.code === 0) {
const pullResult = await gitPull(cachePath, ref, { // Step 1: try pull
disableCredentialHelper: options?.disableCredentialHelper,
sparsePaths,
})
if (pullResult.code === 0) return // Pull worked → done
logForDebugging(
`git pull failed, will re-clone: ${pullResult.stderr}`,
{ level: 'warn' },
)
}
try {
await fs.rm(cachePath, { recursive: true }) // Step 2: DELETE EVERYTHING
logForDebugging(
`Found stale marketplace directory at ${cachePath}, cleaning up...`,
{ level: 'warn' },
)
} catch (rmError) {
if (!isENOENT(rmError)) {
throw new Error(`Failed to clean up existing marketplace directory...`)
}
}
const result = await gitClone( // Step 3: clone fresh
gitUrl, cachePath, ref, sparsePaths
)
if (result.code !== 0) {
try {
await fs.rm(cachePath, { recursive: true, force: true }) // Cleanup partial
} catch { /* ignore */ }
throw new Error( // Step 4: throw (gets swallowed)
`Failed to clone marketplace repository: ${result.stderr}`
)
}
}
```
**Key observation**: Between step 2 and step 3, the marketplace directory does not exist. If the clone fails (network timeout, auth error, GitHub rate limit), the directory stays deleted permanently.
---
### Snippet 2 -- The error gets swallowed
**File**: `claude-code/src/utils/plugins/pluginAutoupdate.ts`, lines 244-256
```typescript
const refreshResults = await Promise.allSettled(
Array.from(autoUpdateEnabledMarketplaces).map(async name => {
try {
await refreshMarketplace(name, undefined, {
disableCredentialHelper: true,
})
} catch (error) {
logForDebugging(
`Plugin autoupdate: failed to refresh marketplace ${name}: ${errorMessage(error)}`,
{ level: 'warn' },
)
// Error swallowed. No recovery. No retry. Directory stays deleted.
}
}),
)
```
**Key observation**: The user sees no error. Debug logs record the failure at `warn` level, but no toast, no banner, no suggestion to run `claude plugin marketplace update`. The marketplace is gone and the user has no idea why plugins stopped working.
---
### Snippet 3 -- The hook executor pre-check
**File**: `claude-code/src/utils/hooks.ts`, lines 824-836
```typescript
if (pluginRoot) {
// Plugin directory gone (orphan GC race, concurrent session deleted it):
// throw so callers yield a non-blocking error.
if (!(await pathExists(pluginRoot))) {
throw new Error(
`Plugin directory does not exist: ${pluginRoot}` +
(pluginId ? ` (${pluginId} -- run /plugin to reinstall)` : ''),
)
}
const rootPath = toHookPath(pluginRoot)
command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => rootPath)
}
```
**Key observation**: The error message says "run /plugin to reinstall" -- but `/plugin` cannot fix a missing marketplace directory. Only `claude plugin marketplace update <name>` recovers it. The error message sends users down a dead end.
---
### Snippet 4 -- The critical typeof branch
**File**: `claude-code/src/utils/plugins/pluginLoader.ts`, lines 2108-2140
```typescript
async function loadPluginFromMarketplaceEntryCacheOnly(
entry: PluginMarketplaceEntry,
marketplaceInstallLocation: string,
pluginId: string,
enabled: boolean,
errorsOut: PluginError[],
installPath: string | undefined,
): Promise<LoadedPlugin | null> {
let pluginPath: string
if (typeof entry.source === 'string') {
// STRING SOURCE: resolves relative to marketplace clone directory.
// If the marketplace clone is gone, this path doesn't exist.
let marketplaceDir: string
try {
marketplaceDir = (await stat(marketplaceInstallLocation)).isDirectory()
? marketplaceInstallLocation
: join(marketplaceInstallLocation, '..')
} catch {
errorsOut.push({
type: 'plugin-cache-miss',
source: pluginId,
plugin: entry.name,
installPath: marketplaceInstallLocation,
})
return null
}
pluginPath = join(marketplaceDir, entry.source)
} else {
// DICT SOURCE: uses recorded installPath from versioned cache.
// Cache survives marketplace deletion.
if (!installPath || !(await pathExists(installPath))) {
errorsOut.push({
type: 'plugin-cache-miss',
source: pluginId,
plugin: entry.name,
installPath: installPath ?? '(not recorded)',
})
return null
}
pluginPath = installPath // <-- THIS IS THE SAFE PATH
}
// ...
}
```
**Key observation**: This is the crux of the fix. Changing `entry.source` from a string (`"./plugins/dev"`) to an object (`{ "source": "git-subdir", ... }`) routes the plugin through the `else` branch, where `pluginPath` is set from the versioned cache directory instead of the marketplace clone.
---
### Snippet 5 -- The GCS special case for official marketplace
**File**: `claude-code/src/utils/plugins/marketplaceManager.ts`, line 2432
```typescript
if (name === OFFICIAL_MARKETPLACE_NAME) {
const sha = await fetchOfficialMarketplaceFromGcs(
installLocation,
getMarketplacesCacheDir(),
)
if (sha !== null) {
config[name] = { ...entry, lastUpdated: new Date().toISOString() }
await saveKnownMarketplacesConfig(config)
return
}
// GCS failed -- fall through to git ONLY if kill-switch allows
}
```
**Key observation**: The official marketplace never goes through `cacheMarketplaceFromGit()`. It downloads from GCS -- a single HTTP request, no intermediate deletion. This is why official plugins never experience the bug.
---
### Snippet 6 -- Where pluginRoot gets set for hooks
**File**: `claude-code/src/utils/plugins/loadPluginHooks.ts`, lines 74-79
```typescript
pluginMatchers[hookEvent].push({
matcher: matcher.matcher,
hooks: matcher.hooks,
pluginRoot: plugin.path, // <-- This determines what the hook executor checks
pluginName: plugin.name,
pluginId: plugin.source,
})
```
**Key observation**: `plugin.path` is set during plugin loading. For string-source plugins, it points into the marketplace clone. For dict-source plugins (git-subdir), it points into the versioned cache. The hook executor's `pathExists(pluginRoot)` check at line 831 of `hooks.ts` then succeeds or fails based on which path was set here.
---
## 4. DATA POINTS AND FACTS
### Marketplace inventory
| Marketplace | Source mechanism | File count | Has `.git` | autoUpdate |
|---|---|---|---|---|
| `claude-plugins-official` | GCS tarball | N/A | No | N/A |
| `claude-code-plugins` | Git clone (`anthropics/claude-code`) | 216 | Yes | Not set |
| `magus` | Git clone (`MadAppGang/magus`) | 4,008 | Yes | `true` |
### Plugin count
### Plugin counts
### Plugin counts
- 16 plugins in `marketplace.json`
- 19 plugin directories on disk (3 not in manifest: `dingo`, `go`, `stats`)
- 8 plugins have hooks using `${CLAUDE_PLUGIN_ROOT}`
- 4 plugins had cross-directory shared dependencies that required migration work
### GitHub issues
8 open issues on `anthropics/claude-code` related to plugin hooks and marketplace behavior:
