diff --git a/src/features/claude-code-plugin-loader/discovery.test.ts b/src/features/claude-code-plugin-loader/discovery.test.ts index 2d4930ac0..7a5dd1b0d 100644 --- a/src/features/claude-code-plugin-loader/discovery.test.ts +++ b/src/features/claude-code-plugin-loader/discovery.test.ts @@ -653,4 +653,471 @@ describe("discoverInstalledPlugins", () => { expect(discovered.plugins[0]?.name).toBe("enabled-plugin") }) }) + + describe("#given installed_plugins.json points to a stale version directory", () => { + function writePluginManifest(installPath: string, manifest: Record): void { + const manifestDir = join(installPath, ".claude-plugin") + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, "plugin.json"), JSON.stringify(manifest), "utf-8") + } + + it("#when configured installPath ends in 'unknown' but a sibling version dir has a plugin manifest #then it is recovered without an error", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-cc-plus-cache-") + const pluginRoot = join(cacheRoot, "cc-plus-marketplace", "cc-plus") + const realInstallPath = join(pluginRoot, "0.1.0") + const configuredInstallPath = join(pluginRoot, "unknown") + mkdirSync(realInstallPath, { recursive: true }) + writePluginManifest(realInstallPath, { name: "cc-plus", version: "0.1.0" }) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "cc-plus@cc-plus-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "unknown", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-stale-unknown`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "cc-plus@cc-plus-marketplace": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.installPath).toBe(realInstallPath) + expect(discovered.plugins[0]?.name).toBe("cc-plus") + }) + + it("#when configured installPath is missing AND no sibling has a plugin manifest #then the original 'path does not exist' error is preserved", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-no-manifest-cache-") + const pluginRoot = join(cacheRoot, "broken-plugin-marketplace", "broken-plugin") + const siblingDir = join(pluginRoot, "0.1.0") + const configuredInstallPath = join(pluginRoot, "unknown") + mkdirSync(siblingDir, { recursive: true }) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "broken-plugin@broken-plugin-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "unknown", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-no-manifest`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "broken-plugin@broken-plugin-marketplace": true }, + }) + + //#then + expect(discovered.plugins).toHaveLength(0) + expect(discovered.errors).toHaveLength(1) + expect(discovered.errors[0]?.installPath).toBe(configuredInstallPath) + expect(discovered.errors[0]?.error).toContain("does not exist") + }) + + it("#when only an 'unknown' sibling exists with a manifest #then it is still picked rather than reporting an error", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-only-unknown-cache-") + const pluginRoot = join(cacheRoot, "weird-plugin-marketplace", "weird-plugin") + const onlySibling = join(pluginRoot, "unknown") + const configuredInstallPath = join(pluginRoot, "ghost") + mkdirSync(onlySibling, { recursive: true }) + writePluginManifest(onlySibling, { name: "weird-plugin", version: "unknown" }) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "weird-plugin@weird-plugin-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "ghost", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-only-unknown`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "weird-plugin@weird-plugin-marketplace": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.installPath).toBe(onlySibling) + }) + + it("#when the recovered version dir uses the legacy root-level plugin.json layout #then it is recognized and the manifest is loaded", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-legacy-manifest-cache-") + const pluginRoot = join(cacheRoot, "legacy-plugin-marketplace", "legacy-plugin") + const realInstallPath = join(pluginRoot, "0.1.0") + const configuredInstallPath = join(pluginRoot, "unknown") + mkdirSync(realInstallPath, { recursive: true }) + writeFileSync( + join(realInstallPath, "plugin.json"), + JSON.stringify({ name: "legacy-plugin", version: "0.1.0" }), + "utf-8", + ) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "legacy-plugin@legacy-plugin-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "unknown", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-legacy-manifest`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "legacy-plugin@legacy-plugin-marketplace": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.installPath).toBe(realInstallPath) + expect(discovered.plugins[0]?.name).toBe("legacy-plugin") + expect(discovered.plugins[0]?.version).toBe("0.1.0") + }) + + it("#when the configured installPath exists #then it is used as-is without scanning siblings", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-existing-path-cache-") + const pluginRoot = join(cacheRoot, "ok-plugin-marketplace", "ok-plugin") + const configuredInstallPath = join(pluginRoot, "1.2.3") + const otherSibling = join(pluginRoot, "0.0.1") + mkdirSync(configuredInstallPath, { recursive: true }) + writePluginManifest(configuredInstallPath, { name: "ok-plugin", version: "1.2.3" }) + mkdirSync(otherSibling, { recursive: true }) + writePluginManifest(otherSibling, { name: "ok-plugin", version: "0.0.1" }) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "ok-plugin@ok-plugin-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "1.2.3", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-existing-path`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "ok-plugin@ok-plugin-marketplace": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.installPath).toBe(configuredInstallPath) + }) + + it("#when multiple non-'unknown' semver siblings are present #then the highest version is picked deterministically", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-multi-version-cache-") + const pluginRoot = join(cacheRoot, "multi-ver-marketplace", "multi-ver") + const oldInstallPath = join(pluginRoot, "0.1.0") + const middleInstallPath = join(pluginRoot, "0.5.3") + const newInstallPath = join(pluginRoot, "1.2.0") + const configuredInstallPath = join(pluginRoot, "unknown") + for (const dir of [oldInstallPath, middleInstallPath, newInstallPath]) { + mkdirSync(join(dir, ".claude-plugin"), { recursive: true }) + writeFileSync( + join(dir, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "multi-ver", version: dir.split("/").pop() }), + "utf-8", + ) + } + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "multi-ver@multi-ver-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "unknown", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-multi-version`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "multi-ver@multi-ver-marketplace": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.installPath).toBe(newInstallPath) + expect(discovered.plugins[0]?.version).toBe("1.2.0") + }) + + it("#when a sibling directory exists with a manifest whose 'name' does NOT match the plugin key #then it is rejected and the error surfaces", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-wrong-name-cache-") + const pluginRoot = join(cacheRoot, "target-plugin-marketplace", "target-plugin") + const maliciousSibling = join(pluginRoot, "0.1.0") + const configuredInstallPath = join(pluginRoot, "unknown") + mkdirSync(join(maliciousSibling, ".claude-plugin"), { recursive: true }) + writeFileSync( + join(maliciousSibling, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "different-plugin", version: "0.1.0" }), + "utf-8", + ) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "target-plugin@target-plugin-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "unknown", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-wrong-name`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "target-plugin@target-plugin-marketplace": true }, + }) + + //#then + expect(discovered.plugins).toHaveLength(0) + expect(discovered.errors).toHaveLength(1) + expect(discovered.errors[0]?.installPath).toBe(configuredInstallPath) + }) + + it("#when two siblings share the same X.Y.Z prefix but one is a prerelease #then the plain version wins deterministically", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-prerelease-cache-") + const pluginRoot = join(cacheRoot, "tie-plugin-marketplace", "tie-plugin") + const plainInstallPath = join(pluginRoot, "1.2.0") + const prereleaseInstallPath = join(pluginRoot, "1.2.0-beta.1") + const configuredInstallPath = join(pluginRoot, "unknown") + for (const dir of [plainInstallPath, prereleaseInstallPath]) { + mkdirSync(join(dir, ".claude-plugin"), { recursive: true }) + writeFileSync( + join(dir, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "tie-plugin", version: dir.split("/").pop() }), + "utf-8", + ) + } + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "tie-plugin@tie-plugin-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "unknown", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-prerelease`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "tie-plugin@tie-plugin-marketplace": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.installPath).toBe(plainInstallPath) + }) + + it("#when a sibling has a malformed manifest that cannot be parsed #then it is rejected under strict name-match", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-malformed-cache-") + const pluginRoot = join(cacheRoot, "strict-plugin-marketplace", "strict-plugin") + const malformedSibling = join(pluginRoot, "0.1.0") + const configuredInstallPath = join(pluginRoot, "unknown") + mkdirSync(join(malformedSibling, ".claude-plugin"), { recursive: true }) + writeFileSync( + join(malformedSibling, ".claude-plugin", "plugin.json"), + "{ this is not valid json", + "utf-8", + ) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "strict-plugin@strict-plugin-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "unknown", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-malformed`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "strict-plugin@strict-plugin-marketplace": true }, + }) + + //#then + expect(discovered.plugins).toHaveLength(0) + expect(discovered.errors).toHaveLength(1) + expect(discovered.errors[0]?.installPath).toBe(configuredInstallPath) + }) + + it("#when a sibling's manifest lacks a 'name' field #then it is rejected under strict name-match", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-noname-cache-") + const pluginRoot = join(cacheRoot, "named-plugin-marketplace", "named-plugin") + const nameMissingSibling = join(pluginRoot, "0.1.0") + const configuredInstallPath = join(pluginRoot, "unknown") + mkdirSync(join(nameMissingSibling, ".claude-plugin"), { recursive: true }) + writeFileSync( + join(nameMissingSibling, ".claude-plugin", "plugin.json"), + JSON.stringify({ version: "0.1.0" }), + "utf-8", + ) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "named-plugin@named-plugin-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "unknown", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-noname`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "named-plugin@named-plugin-marketplace": true }, + }) + + //#then + expect(discovered.plugins).toHaveLength(0) + expect(discovered.errors).toHaveLength(1) + expect(discovered.errors[0]?.installPath).toBe(configuredInstallPath) + }) + + it("#when installation.version is an empty string and manifest.version is also empty #then resolvedVersion falls back to 'unknown' not ''", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const cacheRoot = createTemporaryDirectory("omo-empty-version-cache-") + const pluginRoot = join(cacheRoot, "empty-ver-marketplace", "empty-ver") + const realInstallPath = join(pluginRoot, "0.1.0") + const configuredInstallPath = join(pluginRoot, "unknown") + mkdirSync(join(realInstallPath, ".claude-plugin"), { recursive: true }) + writeFileSync( + join(realInstallPath, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "empty-ver", version: "" }), + "utf-8", + ) + + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "empty-ver@empty-ver-marketplace": [ + { + scope: "user", + installPath: configuredInstallPath, + version: "", + installedAt: "2025-11-01T13:05:32.029Z", + lastUpdated: "2025-11-01T22:22:30.000Z", + }, + ], + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-empty-version`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + enabledPluginsOverride: { "empty-ver@empty-ver-marketplace": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.version).toBe("unknown") + }) + }) }) diff --git a/src/features/claude-code-plugin-loader/discovery.ts b/src/features/claude-code-plugin-loader/discovery.ts index 4a633782b..73b3eabb8 100644 --- a/src/features/claude-code-plugin-loader/discovery.ts +++ b/src/features/claude-code-plugin-loader/discovery.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync } from "fs" +import { existsSync, readdirSync, readFileSync } from "fs" import { homedir } from "os" -import { basename, join } from "path" +import { basename, dirname, join } from "path" import { fileURLToPath } from "url" import { log } from "../../shared/logger" import { shouldLoadPluginForCwd } from "./scope-filter" @@ -65,9 +65,22 @@ function loadClaudeSettings(): ClaudeSettings | null { } } +function findPluginManifestPath(installPath: string): string | null { + const candidates = [ + join(installPath, ".claude-plugin", "plugin.json"), + join(installPath, "plugin.json"), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + return null +} + export function loadPluginManifest(installPath: string): PluginManifest | null { - const manifestPath = join(installPath, ".claude-plugin", "plugin.json") - if (!existsSync(manifestPath)) { + const manifestPath = findPluginManifestPath(installPath) + if (!manifestPath) { return null } @@ -164,6 +177,87 @@ function extractPluginEntries( return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]]) } +function readManifestFromPath(manifestPath: string): PluginManifest | null { + try { + const content = readFileSync(manifestPath, "utf-8") + return JSON.parse(content) as PluginManifest + } catch { + return null + } +} + +function parseSemverPrefix(name: string): [number, number, number] | null { + const match = name.match(/^(\d+)\.(\d+)\.(\d+)/) + if (!match) return null + return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)] +} + +const SEMVER_SUFFIX_MARKER = /^\d+\.\d+\.\d+[-+]/ + +function compareCandidatePriority( + a: { name: string }, + b: { name: string }, +): number { + const aIsUnknown = a.name === "unknown" + const bIsUnknown = b.name === "unknown" + if (aIsUnknown && !bIsUnknown) return 1 + if (!aIsUnknown && bIsUnknown) return -1 + + const aVer = parseSemverPrefix(a.name) + const bVer = parseSemverPrefix(b.name) + if (aVer && bVer) { + if (aVer[0] !== bVer[0]) return bVer[0] - aVer[0] + if (aVer[1] !== bVer[1]) return bVer[1] - aVer[1] + if (aVer[2] !== bVer[2]) return bVer[2] - aVer[2] + const aHasSuffix = SEMVER_SUFFIX_MARKER.test(a.name) + const bHasSuffix = SEMVER_SUFFIX_MARKER.test(b.name) + if (!aHasSuffix && bHasSuffix) return -1 + if (aHasSuffix && !bHasSuffix) return 1 + return a.name.localeCompare(b.name) + } + if (aVer && !bVer) return -1 + if (!aVer && bVer) return 1 + return a.name.localeCompare(b.name) +} + +export function resolveActualInstallPath( + configuredInstallPath: string, + pluginKey?: string, +): string | null { + if (existsSync(configuredInstallPath)) { + return configuredInstallPath + } + const parentDir = dirname(configuredInstallPath) + if (!existsSync(parentDir)) { + return null + } + let entries: string[] + try { + entries = readdirSync(parentDir) + } catch (error) { + log("Failed to scan plugin parent directory for fallback version", { + parentDir, + error, + }) + return null + } + + const expectedName = pluginKey ? derivePluginNameFromKey(pluginKey) : null + + const candidates = entries + .map((name) => ({ name, path: join(parentDir, name) })) + .filter(({ path }) => { + const manifestPath = findPluginManifestPath(path) + if (!manifestPath) return false + if (expectedName === null) return true + const manifest = readManifestFromPath(manifestPath) + if (!manifest?.name) return false + return manifest.name === expectedName + }) + .sort(compareCandidatePriority) + return candidates[0]?.path ?? null +} + export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult { // Allow overriding the plugins base directory for testing const pluginsBaseDir = options?.pluginsHomeOverride ?? getPluginsBaseDir() @@ -197,23 +291,42 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL continue } - const { installPath, scope, version } = installation + const { installPath: configuredInstallPath, scope, version } = installation - if (!existsSync(installPath)) { + const installPath = resolveActualInstallPath(configuredInstallPath, pluginKey) + if (!installPath) { errors.push({ pluginKey, - installPath, + installPath: configuredInstallPath, error: "Plugin installation path does not exist", }) continue } + if (installPath !== configuredInstallPath) { + log(`Recovered plugin install path for ${pluginKey}`, { + configured: configuredInstallPath, + resolved: installPath, + }) + } + const manifest = pluginManifestLoader(installPath) const pluginName = manifest?.name || derivePluginNameFromKey(pluginKey) + const installationVersionTrim = typeof version === "string" ? version.trim() : "" + const installationVersion = + installationVersionTrim !== "" && installationVersionTrim !== "unknown" + ? version + : null + const manifestVersionTrim = + typeof manifest?.version === "string" ? manifest.version.trim() : "" + const manifestVersion = manifestVersionTrim !== "" ? manifest?.version : null + const rawVersion = installationVersionTrim !== "" ? version : null + const resolvedVersion = installationVersion ?? manifestVersion ?? rawVersion ?? "unknown" + const loadedPlugin: LoadedPlugin = { name: pluginName, - version: version || manifest?.version || "unknown", + version: resolvedVersion, scope: scope as PluginScope, installPath, pluginKey,