From 51b2b6d1ed04c57978afef82057a73739068b9d6 Mon Sep 17 00:00:00 2001 From: Andrew Qu Date: Thu, 9 Apr 2026 10:09:57 -0700 Subject: [PATCH 1/3] init --- packages/add-plugin/lib/install.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/add-plugin/lib/install.ts b/packages/add-plugin/lib/install.ts index 448fc62..8b0a112 100644 --- a/packages/add-plugin/lib/install.ts +++ b/packages/add-plugin/lib/install.ts @@ -294,10 +294,13 @@ async function installToPluginCache( for (const plugin of plugins) { const pluginRef = `${plugin.name}@${marketplaceName}`; const version = plugin.version ?? "0.0.0"; + // Use truncated git SHA for cache path (matches official Claude installer), + // falling back to semver for non-git sources. + const versionKey = gitSha ? gitSha.slice(0, 12) : version; step(`Installing ${c.bold(pluginRef)}...`); // Copy plugin directory to cache - const cacheDest = join(cacheDir, marketplaceName, plugin.name, version); + const cacheDest = join(cacheDir, marketplaceName, plugin.name, versionKey); await mkdir(cacheDest, { recursive: true }); await cp(plugin.path, cacheDest, { recursive: true }); barDebug(c.dim(`Cached to ${cacheDest}`)); @@ -380,7 +383,10 @@ async function installToCursorExtensions( for (const plugin of plugins) { const pluginRef = `${plugin.name}@${marketplaceName}`; const version = plugin.version ?? "0.0.0"; - const folderName = `${marketplaceName}.${plugin.name}-${version}`; + // Use git SHA in folder name to match official installer conventions, + // falling back to semver for non-git sources. + const versionKey = gitSha ? gitSha.slice(0, 12) : version; + const folderName = `${marketplaceName}.${plugin.name}-${versionKey}`; const destDir = join(extensionsDir, folderName); step(`Installing ${c.bold(pluginRef)}...`); From 34c6ffe83646d8e773df0d6d24b264f8ae84362a Mon Sep 17 00:00:00 2001 From: Andrew Qu Date: Thu, 9 Apr 2026 15:24:34 -0700 Subject: [PATCH 2/3] works --- packages/add-plugin/lib/install.ts | 188 +++++++++++++++-------------- packages/add-plugin/package.json | 2 +- 2 files changed, 96 insertions(+), 94 deletions(-) diff --git a/packages/add-plugin/lib/install.ts b/packages/add-plugin/lib/install.ts index 8b0a112..5d50342 100644 --- a/packages/add-plugin/lib/install.ts +++ b/packages/add-plugin/lib/install.ts @@ -112,80 +112,10 @@ async function installToClaudeCode( repoPath: string, source: string, ): Promise { - const marketplaceName = plugins[0]?.marketplace ?? deriveMarketplaceName(source); - - // 1. Prepare the repo directory for Claude Code - step("Preparing plugins for Claude Code..."); - barEmpty(); - await prepareForClaudeCode(plugins, repoPath, marketplaceName); - - // 2. Add the marketplace - // For official Anthropic marketplaces, pass the GitHub URL directly so the - // claude CLI recognises the source as coming from the 'anthropics' org. - // Otherwise it rejects the reserved marketplace name. - const marketplaceSource = isAnthropicSource(source) ? normalizeGitUrl(source) : repoPath; - - const claudePath = findClaude(); - step("Adding marketplace"); - barDebug(c.dim(`Binary: ${claudePath}`)); - try { - const version = execSync(`${claudePath} --version`, { encoding: "utf-8", stdio: "pipe" }).trim(); - barDebug(c.dim(`Version: ${version}`)); - } catch { - barDebug(c.dim(`Warning: could not get claude version`)); - } - - try { - const result = execSync(`${claudePath} plugin marketplace add ${marketplaceSource}`, { - encoding: "utf-8", - stdio: "pipe", - }); - if (result.trim()) barDebug(c.dim(result.trim())); - stepDone("Marketplace added"); - } catch (err: any) { - const stderr = err.stderr?.toString().trim() ?? ""; - const stdout = err.stdout?.toString().trim() ?? ""; - if (stderr.includes("already") || stdout.includes("already")) { - stepDone(`Marketplace ${c.dim("'"+marketplaceName+"'")} already on disk`); - } else { - stepError("Failed to add marketplace."); - barLine(c.dim(`Command: ${claudePath} plugin marketplace add ${marketplaceSource}`)); - if (stdout) barLine(c.dim(`stdout: ${stdout}`)); - if (stderr) barLine(c.dim(`stderr: ${stderr}`)); - barLine(c.dim(`exit code: ${err.status}`)); - process.exit(1); - } - } - - barEmpty(); - - // 3. Install each plugin - for (const plugin of plugins) { - const pluginRef = `${plugin.name}@${marketplaceName}`; - step(`Installing ${c.bold(pluginRef)}...`); - - try { - execSync(`${claudePath} plugin install ${pluginRef} --scope ${scope}`, { - encoding: "utf-8", - stdio: "pipe", - }); - stepDone(`Installed ${c.cyan(pluginRef)}`); - } catch (err: any) { - const stderr = err.stderr?.toString().trim() ?? ""; - const stdout = err.stdout?.toString().trim() ?? ""; - if (stderr.includes("already") || stdout.includes("already")) { - stepDone(`${c.cyan(pluginRef)} ${c.dim("already installed")}`); - } else { - stepError(`Failed to install ${pluginRef}`); - barLine(c.dim(`Command: ${claudePath} plugin install ${pluginRef} --scope ${scope}`)); - if (stdout) barLine(c.dim(`stdout: ${stdout}`)); - if (stderr) barLine(c.dim(`stderr: ${stderr}`)); - barLine(c.dim(`exit code: ${err.status}`)); - } - } - } - - cachePopulated = true; + // Install directly to the plugin cache using git SHA-based paths (matches + // the official Claude installer convention). We no longer shell out to the + // `claude` CLI because it uses semver-based paths instead of commit hashes. + await installToPluginCache(plugins, scope, repoPath, source); } // --------------------------------------------------------------------------- @@ -193,10 +123,8 @@ async function installToClaudeCode( // --------------------------------------------------------------------------- // // Cursor reads "Imported" plugins from the Claude Code plugin cache directory -// (~/.claude/plugins/). When the `claude` CLI is available, we use it (same as -// Claude Code). When it's not available, we write directly to the cache -// directory, registering the marketplace and plugins in the JSON manifests -// that Cursor reads. +// (~/.claude/plugins/). We write directly to the cache using git SHA-based +// paths. On Windows, Cursor reads from ~/.cursor/extensions/ instead. async function installToCursor( plugins: DiscoveredPlugin[], @@ -208,16 +136,14 @@ async function installToCursor( // populated and Cursor will pick it up — nothing more to do. if (cachePopulated) return; - const claudePath = findClaudeOrNull(); - - if (claudePath) { - // Claude CLI available — use it (same mechanism as Claude Code target). - await installToClaudeCode(plugins, scope, repoPath, source); + if (process.platform === "win32") { + // Windows: Cursor reads from ~/.cursor/extensions/ + await installToCursorExtensions(plugins, scope, repoPath, source); return; } - // No claude CLI — install directly to Cursor's extensions directory. - await installToCursorExtensions(plugins, scope, repoPath, source); + // macOS/Linux: write directly to ~/.claude/plugins/ cache with git SHA paths. + await installToPluginCache(plugins, scope, repoPath, source); } // --------------------------------------------------------------------------- @@ -258,12 +184,39 @@ async function installToPluginCache( } } + // Determine the marketplace source format to match what the official + // Claude installer writes: + // - GitHub repos → { source: "github", repo: "owner/repo" } + // - Other git URLs → { source: "git", url: "https://..." } + // - Local paths → { source: "directory", path: "/abs/path" } + const githubRepo = extractGitHubRepo(source); + const marketplacesDir = join(pluginsDir, "marketplaces"); + const marketplaceInstallLocation = join(marketplacesDir, marketplaceName); + + // Copy the repo to the marketplaces directory so Claude Code can find + // plugins when validating against the marketplace. + await mkdir(marketplacesDir, { recursive: true }); + if (existsSync(marketplaceInstallLocation)) { + await rm(marketplaceInstallLocation, { recursive: true }); + } + await cp(repoPath, marketplaceInstallLocation, { recursive: true }); + barDebug(c.dim(`Marketplace copied to ${marketplaceInstallLocation}`)); + if (knownMarketplaces[marketplaceName]) { stepDone(`Marketplace ${c.dim("'" + marketplaceName + "'")} already registered`); } else { + let marketplaceSource: Record; + if (githubRepo) { + marketplaceSource = { source: "github", repo: githubRepo }; + } else if (isRemoteSource(source)) { + const gitUrl = normalizeGitUrl(source); + marketplaceSource = { source: "git", url: gitUrl.endsWith(".git") ? gitUrl : gitUrl + ".git" }; + } else { + marketplaceSource = { source: "directory", path: repoPath }; + } knownMarketplaces[marketplaceName] = { - source: { source: "directory", path: repoPath }, - installLocation: repoPath, + source: marketplaceSource, + installLocation: marketplaceInstallLocation, lastUpdated: new Date().toISOString(), }; await writeFile(knownPath, JSON.stringify(knownMarketplaces, null, 2)); @@ -326,6 +279,30 @@ async function installToPluginCache( await writeFile(installedPath, JSON.stringify(installedData, null, 2)); barDebug(c.dim("Updated installed_plugins.json")); + // 4. Enable plugins in ~/.claude/settings.json + // Claude Code uses the `enabledPlugins` map in settings.json to determine + // which installed plugins are actually active. Without this, plugins show + // in "Discover" but not in "Installed". + const settingsPath = join(home, ".claude", "settings.json"); + let settings: Record = {}; + if (existsSync(settingsPath)) { + try { + settings = JSON.parse(await readFile(settingsPath, "utf-8")); + } catch { + // corrupted — start fresh + } + } + + const enabled = (settings.enabledPlugins ?? {}) as Record; + for (const plugin of plugins) { + const pluginKey = `${plugin.name}@${marketplaceName}`; + enabled[pluginKey] = true; + } + settings.enabledPlugins = enabled; + + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); + barDebug(c.dim("Updated settings.json enabledPlugins")); + cachePopulated = true; } @@ -343,12 +320,6 @@ async function installToCursorExtensions( repoPath: string, source: string, ): Promise { - if (process.platform !== "win32") { - // macOS/Linux: use the Claude plugin cache (Cursor reads from there) - await installToPluginCache(plugins, scope, repoPath, source); - return; - } - // Windows: install to ~/.cursor/extensions/ const marketplaceName = plugins[0]?.marketplace ?? deriveMarketplaceName(source); const home = homedir(); @@ -884,6 +855,37 @@ function deriveMarketplaceName(source: string): string { return parts[parts.length - 1] ?? "plugins"; } +/** + * Extract a GitHub "owner/repo" string from a source, or null if the source + * is not a GitHub reference. + */ +function extractGitHubRepo(source: string): string | null { + // Shorthand: owner/repo + const shorthand = source.match(/^([\w-]+\/[\w.-]+)$/); + if (shorthand) return shorthand[1]!; + // HTTPS: https://github.com/owner/repo[.git] + const httpsMatch = source.match(/^https?:\/\/github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]!; + // SSH: git@github.com:owner/repo[.git] + const sshMatch = source.match(/^git@github\.com:([\w.-]+\/[\w.-]+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]!; + return null; +} + +/** + * Check if a source string is a remote git source (URL or owner/repo shorthand) + * rather than a local file path. + */ +function isRemoteSource(source: string): boolean { + // GitHub shorthand: owner/repo + if (source.match(/^[\w-]+\/[\w.-]+$/)) return true; + // SSH URL + if (source.startsWith("git@")) return true; + // HTTPS/HTTP URL + if (source.startsWith("https://") || source.startsWith("http://")) return true; + return false; +} + /** * Check if a source string points to the anthropics GitHub org. */ diff --git a/packages/add-plugin/package.json b/packages/add-plugin/package.json index a916b32..3c37bac 100644 --- a/packages/add-plugin/package.json +++ b/packages/add-plugin/package.json @@ -1,6 +1,6 @@ { "name": "plugins", - "version": "1.2.9", + "version": "1.2.10-canary.5", "description": "Install open-plugin format plugins into agent tools", "type": "module", "bin": { From b592a7989d11a84a8a8d6c8b8d81c7339c761875 Mon Sep 17 00:00:00 2001 From: Vercel Date: Fri, 10 Apr 2026 04:37:11 +0000 Subject: [PATCH 3/3] Fix: When `~/.claude/settings.json` fails to parse, the catch block silently resets settings to `{}` and then writes it back, destroying all existing user settings (permissions, model preferences, allowed/blocked tools, etc.). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/add-plugin/lib/install.ts:291 ## Bug Analysis **Why it happens:** In `installToPluginCache()`, when reading `~/.claude/settings.json`, if the file exists but `JSON.parse` throws (e.g., due to trailing commas, minor corruption, comments, BOM characters), the catch block silently falls through with `settings = {}`. The function then proceeds to write `{ "enabledPlugins": { ... } }` back to disk, overwriting the entire file. **When it manifests:** Any time `settings.json` contains valid-for-Claude-but-invalid-for-strict-JSON content (trailing commas, comments, BOM, or actual corruption). JSON5-style files that Claude Code may tolerate would trigger this. **Impact:** This is a data-loss bug. `settings.json` is a shared config file owned by Claude Code that contains many important user-managed settings beyond `enabledPlugins` — permissions, model preferences, allowed/blocked tools, etc. The "start fresh" pattern that's safe for plugin-specific files (`known_marketplaces.json`, `installed_plugins.json`) is destructive when applied to this shared config file. **Contrast with other files:** For `known_marketplaces.json` and `installed_plugins.json`, starting fresh on parse failure is acceptable because `installToPluginCache` is the sole writer/owner of those files. But `settings.json` belongs to Claude Code and other tools may write to it. ## Fix Explanation The fix introduces a `settingsCorrupted` flag. When `JSON.parse` fails on an existing `settings.json`, instead of proceeding with an empty object (which would overwrite all settings), the code: 1. Warns the user that `settings.json` couldn't be parsed 2. Informs them they may need to manually enable plugins 3. Skips writing to `settings.json` entirely When the file doesn't exist (the normal first-run case), `settings` remains `{}` and `settingsCorrupted` stays `false`, so the file is correctly created with just `enabledPlugins`. Also added `warn` to the import from `./ui.js`. Co-authored-by: Vercel Co-authored-by: quuu --- packages/add-plugin/lib/install.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/add-plugin/lib/install.ts b/packages/add-plugin/lib/install.ts index 5d50342..64fac18 100644 --- a/packages/add-plugin/lib/install.ts +++ b/packages/add-plugin/lib/install.ts @@ -17,7 +17,7 @@ import { homedir } from "os"; import { createHash } from "crypto"; import type { DiscoveredPlugin } from "./discover.js"; import type { Target } from "./targets.js"; -import { c, step, stepDone, stepError, barLine, barEmpty, barDebug } from "./ui.js"; +import { c, step, stepDone, stepError, barLine, barEmpty, barDebug, warn } from "./ui.js"; /** * Track whether the plugin cache has already been populated (by the Claude Code @@ -285,23 +285,29 @@ async function installToPluginCache( // in "Discover" but not in "Installed". const settingsPath = join(home, ".claude", "settings.json"); let settings: Record = {}; + let settingsCorrupted = false; if (existsSync(settingsPath)) { try { settings = JSON.parse(await readFile(settingsPath, "utf-8")); } catch { - // corrupted — start fresh + settingsCorrupted = true; } } - const enabled = (settings.enabledPlugins ?? {}) as Record; - for (const plugin of plugins) { - const pluginKey = `${plugin.name}@${marketplaceName}`; - enabled[pluginKey] = true; - } - settings.enabledPlugins = enabled; + if (settingsCorrupted) { + warn("Could not parse ~/.claude/settings.json — skipping enabledPlugins update to avoid overwriting existing settings."); + barLine(c.dim("You may need to manually enable the plugins in Claude Code settings.")); + } else { + const enabled = (settings.enabledPlugins ?? {}) as Record; + for (const plugin of plugins) { + const pluginKey = `${plugin.name}@${marketplaceName}`; + enabled[pluginKey] = true; + } + settings.enabledPlugins = enabled; - await writeFile(settingsPath, JSON.stringify(settings, null, 2)); - barDebug(c.dim("Updated settings.json enabledPlugins")); + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); + barDebug(c.dim("Updated settings.json enabledPlugins")); + } cachePopulated = true; }