From 7a8e4e021e9964ead708302a2bd37a23b691616f Mon Sep 17 00:00:00 2001 From: Miroslav Drbal Date: Sat, 28 Mar 2026 17:38:23 +0100 Subject: [PATCH] fix: preserve --plugin-dir across cache clears, safe force-reinstall, URL classification - Store injected --plugin-dir roots in module-level variable; survive cache clears by re-merging on next listClaudePluginRoots() call - Defer old plugin deletion in force-reinstall until new cache succeeds; transient failures no longer leave the plugin uninstalled - Classify HTTP URLs by pathname instead of full string; query params no longer cause .json URLs to be misclassified as git --- .../coding-agent/src/discovery/helpers.ts | 13 ++++++++- .../plugins/marketplace/fetcher.ts | 8 +++++- .../plugins/marketplace/manager.ts | 28 +++++++++++-------- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index b2141f9bf..54ece903c 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -746,6 +746,14 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau } } + // Merge --plugin-dir roots (highest precedence) on every fresh load + if (injectedPluginDirRoots.length > 0) { + const injectedIds = new Set(injectedPluginDirRoots.map(r => r.id)); + const filtered = roots.filter(r => !injectedIds.has(r.id)); + roots.length = 0; + roots.push(...injectedPluginDirRoots, ...filtered); + } + const result = { roots, warnings }; pluginRootsCache.set(home, result); return result; @@ -756,7 +764,7 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau */ export function clearClaudePluginRootsCache(): void { pluginRootsCache.clear(); - preloadedPluginRoots = []; + preloadedPluginRoots = [...injectedPluginDirRoots]; } // ── Preloaded plugin roots (for sync consumers like LSP config) ───────────── @@ -764,6 +772,7 @@ export function clearClaudePluginRootsCache(): void { // getPreloadedPluginRoots(). Safe degradation: empty array if not warmed. let preloadedPluginRoots: ClaudePluginRoot[] = []; +let injectedPluginDirRoots: ClaudePluginRoot[] = []; /** * Populate the module-level plugin roots cache for sync consumers. @@ -815,6 +824,8 @@ export async function injectPluginDirRoots(home: string, dirs: string[]): Promis // --plugin-dir roots have highest precedence: prepend them, // removing any existing entries with the same plugin ID. + injectedPluginDirRoots = injected; + const injectedIds = new Set(injected.map(r => r.id)); const filtered = roots.filter(r => !injectedIds.has(r.id)); const merged = [...injected, ...filtered]; diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts index b628c16b0..10dfeb69c 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts @@ -50,7 +50,13 @@ const GITHUB_SHORTHAND_RE = /^[a-z0-9-]+\/[a-z0-9._-]+$/i; export function classifySource(source: string): MarketplaceSourceType { // Rule 1: HTTP(S) URLs — .json suffix → url, everything else → git if (source.startsWith("https://") || source.startsWith("http://")) { - return source.endsWith(".json") ? "url" : "git"; + try { + const { pathname } = new URL(source); + return pathname.endsWith(".json") ? "url" : "git"; + } catch { + // Malformed URL — treat as git + return "git"; + } } // Rule 2: SCP-style SSH git URLs diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts index 0092c8027..d95b69ca7 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts @@ -200,17 +200,8 @@ export class MarketplaceManager { // 3. Check if already installed const instReg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath); const existing = getInstalledPlugin(instReg, pluginId); - if (existing && existing.length > 0) { - if (!force) { - throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`); - } - // Remove all cached entries for this plugin (there may be multiple versions) - for (const entry of existing) { - await fs.rm(entry.installPath, { recursive: true, force: true }); - } - const prunedReg = removeInstalledPlugin(instReg, pluginId); - await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, prunedReg); - this.#opts.clearPluginRootsCache?.(); + if (existing && existing.length > 0 && !force) { + throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`); } // 4. Resolve source path. @@ -245,6 +236,21 @@ export class MarketplaceManager { } } + // Only now clean up old entries — new cache succeeded, so it is safe to remove old ones. + if (existing && existing.length > 0) { + for (const entry of existing) { + // Skip if the new cache resolved to the same path (same version reinstall). + if (entry.installPath !== cachePath) { + await fs.rm(entry.installPath, { recursive: true, force: true }); + } + } + const prunedReg = removeInstalledPlugin( + await readInstalledPluginsRegistry(this.#opts.installedRegistryPath), + pluginId, + ); + await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, prunedReg); + } + // 6. Build and register the entry const now = new Date().toISOString(); const installedEntry: InstalledPluginEntry = {