From 24b249219edcfda8ed8e052076ed5d3381910ab5 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 25 May 2026 17:34:32 +0000 Subject: [PATCH] fix(lsp): supported config-only marketplace servers Loaded marketplace lspServers metadata from Claude plugin caches and embedded it for OMP marketplace installs so config-only plugins register without package code. Fixes #1352 --- packages/coding-agent/CHANGELOG.md | 1 + .../plugins/marketplace/manager.ts | 21 +++- packages/coding-agent/src/lsp/config.ts | 109 ++++++++++++++---- .../test/marketplace/manager.test.ts | 45 ++++++++ .../test/tools/lsp-regressions.test.ts | 86 ++++++++++++++ 5 files changed, 239 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2b6d919a1..73b4767a9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280)) +- Fixed config-only marketplace LSP plugins such as `csharp-lsp` not registering servers with the CLI when the plugin cache has only marketplace metadata and no package code ([#1352](https://github.com/can1357/oh-my-pi/issues/1352)). ## [15.2.4] - 2026-05-22 ### Breaking Changes diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts index f020aee4e..049583cbf 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts @@ -10,7 +10,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils"; import { cachePlugin } from "./cache"; import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher"; @@ -290,6 +290,7 @@ export class MarketplaceManager { try { version = await this.#resolvePluginVersion(pluginEntry, sourcePath); cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version); + await this.#writeEmbeddedLspConfig(pluginEntry, cachePath); } finally { // Clean up temp clone dirs created by resolvePluginSource; leave user-supplied local dirs alone if (tempCloneRoot) { @@ -342,6 +343,24 @@ export class MarketplaceManager { return installedEntry; } + async #writeEmbeddedLspConfig(entry: MarketplacePluginEntry, cachePath: string): Promise { + const lspServers = entry.lspServers; + if (!lspServers) return; + + const targetPath = path.join(cachePath, ".lsp.json"); + if (typeof lspServers === "string") { + const sourcePath = path.resolve(cachePath, lspServers); + if (!pathIsWithin(cachePath, sourcePath)) { + throw new Error(`Plugin "${entry.name}" lspServers path escapes the plugin directory`); + } + const content = await Bun.file(sourcePath).text(); + await Bun.write(targetPath, content); + return; + } + + await Bun.write(targetPath, `${JSON.stringify({ servers: lspServers }, null, 2)}\n`); + } + /** * Resolve plugin version from multiple sources: * 1. Catalog entry version (if set) diff --git a/packages/coding-agent/src/lsp/config.ts b/packages/coding-agent/src/lsp/config.ts index 52c48aa73..1ffb12e5a 100644 --- a/packages/coding-agent/src/lsp/config.ts +++ b/packages/coding-agent/src/lsp/config.ts @@ -1,10 +1,10 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { $which, isRecord, logger } from "@oh-my-pi/pi-utils"; +import { $which, isRecord, logger, pathIsWithin } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { getConfigDirPaths } from "../config"; -import { getPreloadedPluginRoots } from "../discovery/helpers"; +import { type ClaudePluginRoot, getPreloadedPluginRoots } from "../discovery/helpers"; import { BiomeClient } from "./clients/biome-client"; import { SwiftLintClient } from "./clients/swiftlint-client"; import DEFAULTS from "./defaults.json" with { type: "json" }; @@ -22,8 +22,13 @@ export interface LspConfig { const PID_TOKEN = "$PID"; +interface RawServerConfig extends Partial { + extensionToLanguage?: unknown; + initializationOptions?: unknown; +} + interface NormalizedConfig { - servers: Record>; + servers: Record; idleTimeoutMs?: number; } @@ -42,12 +47,12 @@ function normalizeConfig(value: unknown): NormalizedConfig | null { const rawServers = value.servers; if (isRecord(rawServers)) { - return { servers: rawServers as Record>, idleTimeoutMs }; + return { servers: rawServers as Record, idleTimeoutMs }; } const servers = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "idleTimeoutMs")) as Record< string, - Partial + RawServerConfig >; return { servers, idleTimeoutMs }; @@ -58,11 +63,17 @@ function normalizeStringArray(value: unknown): string[] | null { const items = value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); return items.length > 0 ? items : null; } +function normalizeExtensionToFileTypes(value: unknown): string[] | null { + if (!isRecord(value)) return null; + const extensions = Object.keys(value).filter(extension => extension.length > 0); + return extensions.length > 0 ? extensions : null; +} -function normalizeServerConfig(name: string, config: Partial): ServerConfig | null { +function normalizeServerConfig(name: string, config: RawServerConfig): ServerConfig | null { const command = typeof config.command === "string" && config.command.length > 0 ? config.command : null; - const fileTypes = normalizeStringArray(config.fileTypes); - const rootMarkers = normalizeStringArray(config.rootMarkers); + const fileTypes = + normalizeStringArray(config.fileTypes) ?? normalizeExtensionToFileTypes(config.extensionToLanguage); + const rootMarkers = normalizeStringArray(config.rootMarkers) ?? (config.extensionToLanguage ? ["."] : null); if (!command || !fileTypes || !rootMarkers) { logger.warn("Ignoring invalid LSP server config (missing required fields).", { name }); @@ -72,6 +83,11 @@ function normalizeServerConfig(name: string, config: Partial): Ser const args = Array.isArray(config.args) ? config.args.filter((entry): entry is string => typeof entry === "string") : undefined; + const initOptions = isRecord(config.initOptions) + ? config.initOptions + : isRecord(config.initializationOptions) + ? config.initializationOptions + : undefined; return { ...config, @@ -79,6 +95,7 @@ function normalizeServerConfig(name: string, config: Partial): Ser args, fileTypes, rootMarkers, + ...(initOptions ? { initOptions } : {}), }; } @@ -92,7 +109,7 @@ function readConfigFile(filePath: string): NormalizedConfig | null { } } -function coerceServerConfigs(servers: Record>): Record { +function coerceServerConfigs(servers: Record): Record { const result: Record = {}; for (const [name, config] of Object.entries(servers)) { const normalized = normalizeServerConfig(name, config); @@ -105,7 +122,7 @@ function coerceServerConfigs(servers: Record>): Re function mergeServers( base: Record, - overrides: Record>, + overrides: Record, ): Record { const merged: Record = { ...base }; for (const [name, config] of Object.entries(overrides)) { @@ -245,24 +262,71 @@ export function resolveCommand(command: string, cwd: string): string | null { return $which(command); } +interface ConfigSource { + read(): NormalizedConfig | null; +} + +function fileConfigSource(filePath: string): ConfigSource { + return { + read: () => readConfigFile(filePath), + }; +} + +function readMarketplaceLspConfig(root: ClaudePluginRoot): NormalizedConfig | null { + const catalogPaths = [ + path.resolve(root.path, "..", "..", "marketplace.json"), + path.resolve(root.path, "..", "..", ".claude-plugin", "marketplace.json"), + ]; + + for (const catalogPath of catalogPaths) { + try { + const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf-8")) as unknown; + if (!isRecord(catalog) || !Array.isArray(catalog.plugins)) continue; + + for (const plugin of catalog.plugins) { + if (!isRecord(plugin) || plugin.name !== root.plugin) continue; + + const lspServers = plugin.lspServers; + if (typeof lspServers === "string") { + const configPath = path.resolve(root.path, lspServers); + if (!pathIsWithin(root.path, configPath)) return null; + return readConfigFile(configPath); + } + if (isRecord(lspServers)) { + return normalizeConfig({ servers: lspServers }); + } + return null; + } + } catch {} + } + + return null; +} + +function marketplaceConfigSource(root: ClaudePluginRoot): ConfigSource { + return { + read: () => readMarketplaceLspConfig(root), + }; +} + /** - * Configuration file search paths (in priority order). + * Configuration sources in priority order. * Supports both visible and hidden variants at each config location. */ -function getConfigPaths(cwd: string): string[] { +function getConfigSources(cwd: string): ConfigSource[] { const filenames = ["lsp.json", ".lsp.json", "lsp.yaml", ".lsp.yaml", "lsp.yml", ".lsp.yml"]; - const paths: string[] = []; + const sources: ConfigSource[] = []; // Project root files (highest priority) for (const filename of filenames) { - paths.push(path.join(cwd, filename)); + sources.push(fileConfigSource(path.join(cwd, filename))); } // Project config directories (.omp/, .pi/, .claude/) const projectDirs = getConfigDirPaths("", { user: false, project: true, cwd }); for (const dir of projectDirs) { for (const filename of filenames) { - paths.push(path.join(dir, filename)); + sources.push(fileConfigSource(path.join(dir, filename))); } } @@ -270,7 +334,7 @@ function getConfigPaths(cwd: string): string[] { const userDirs = getConfigDirPaths("", { user: true, project: false }); for (const dir of userDirs) { for (const filename of filenames) { - paths.push(path.join(dir, filename)); + sources.push(fileConfigSource(path.join(dir, filename))); } } @@ -278,16 +342,17 @@ function getConfigPaths(cwd: string): string[] { const pluginRoots = getPreloadedPluginRoots(); for (const root of pluginRoots) { for (const filename of filenames) { - paths.push(path.join(root.path, filename)); + sources.push(fileConfigSource(path.join(root.path, filename))); } + sources.push(marketplaceConfigSource(root)); } // User home root files (lowest priority fallback) for (const filename of filenames) { - paths.push(path.join(os.homedir(), filename)); + sources.push(fileConfigSource(path.join(os.homedir(), filename))); } - return paths; + return sources; } /** @@ -324,12 +389,12 @@ function getConfigPaths(cwd: string): string[] { export function loadConfig(cwd: string): LspConfig { let mergedServers = coerceServerConfigs(DEFAULTS); - const configPaths = getConfigPaths(cwd).reverse(); + const configSources = getConfigSources(cwd).reverse(); let hasOverrides = false; let idleTimeoutMs: number | undefined; - for (const configPath of configPaths) { - const parsed = readConfigFile(configPath); + for (const source of configSources) { + const parsed = source.read(); if (!parsed) continue; const hasServerOverrides = Object.keys(parsed.servers).length > 0; if (hasServerOverrides) { diff --git a/packages/coding-agent/test/marketplace/manager.test.ts b/packages/coding-agent/test/marketplace/manager.test.ts index f974bf602..65369f047 100644 --- a/packages/coding-agent/test/marketplace/manager.test.ts +++ b/packages/coding-agent/test/marketplace/manager.test.ts @@ -148,6 +148,51 @@ describe("MarketplaceManager", () => { expect(installed[0].id).toBe("hello-plugin@test-marketplace"); }); + it("installPlugin embeds config-only marketplace LSP metadata", async () => { + const marketplaceDir = path.join(ctx.tmpDir, "config-only-marketplace"); + const pluginDir = path.join(marketplaceDir, "plugins", "csharp-lsp"); + await fs.promises.mkdir(pluginDir, { recursive: true }); + await Bun.write(path.join(pluginDir, "README.md"), "config-only C# LSP plugin\n"); + await fs.promises.mkdir(path.join(marketplaceDir, ".claude-plugin"), { recursive: true }); + await Bun.write( + path.join(marketplaceDir, ".claude-plugin", "marketplace.json"), + `${JSON.stringify( + { + name: "config-only-marketplace", + owner: { name: "Test Author" }, + plugins: [ + { + name: "csharp-lsp", + source: "./plugins/csharp-lsp", + version: "1.0.0", + lspServers: { + "csharp-ls": { + command: "csharp-ls", + extensionToLanguage: { ".cs": "csharp" }, + }, + }, + }, + ], + }, + null, + 2, + )}\n`, + ); + + await ctx.manager.addMarketplace(marketplaceDir); + const instEntry = await ctx.manager.installPlugin("csharp-lsp", "config-only-marketplace"); + + const lspConfig = await Bun.file(path.join(instEntry.installPath, ".lsp.json")).json(); + expect(lspConfig).toEqual({ + servers: { + "csharp-ls": { + command: "csharp-ls", + extensionToLanguage: { ".cs": "csharp" }, + }, + }, + }); + }); + it("installPlugin with scope:project → stores project scope", async () => { await ctx.manager.addMarketplace(FIXTURE_DIR); const instEntry = await ctx.manager.installPlugin("hello-plugin", "test-marketplace", { diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 2914eee63..f0d28f680 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core"; +import { preloadPluginRoots } from "@oh-my-pi/pi-coding-agent/discovery/helpers"; import { LspTool } from "@oh-my-pi/pi-coding-agent/lsp"; import * as lspClient from "@oh-my-pi/pi-coding-agent/lsp/client"; import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config"; @@ -422,6 +423,91 @@ describe("lsp regressions", () => { tempDir.removeSync(); } }); + + it("loads config-only marketplace LSP servers from Claude plugin cache", async () => { + const tempDir = TempDir.createSync("@omp-lsp-marketplace-config-"); + const home = path.join(tempDir.path(), "home"); + const cwd = path.join(tempDir.path(), "repo"); + const pluginRoot = path.join( + home, + ".claude", + "plugins", + "cache", + "claude-plugins-official", + "csharp-lsp", + "1.0.0", + ); + const marketplaceRoot = path.dirname(path.dirname(pluginRoot)); + const registryPath = path.join(home, ".claude", "plugins", "installed_plugins.json"); + + await fs.promises.mkdir(pluginRoot, { recursive: true }); + await fs.promises.mkdir(cwd, { recursive: true }); + await fs.promises.mkdir(path.dirname(registryPath), { recursive: true }); + await Bun.write(path.join(cwd, "Example.csproj"), "\n"); + await Bun.write( + registryPath, + `${JSON.stringify( + { + version: 2, + plugins: { + "csharp-lsp@claude-plugins-official": [ + { + scope: "user", + installPath: pluginRoot, + version: "1.0.0", + installedAt: "2026-05-25T00:00:00.000Z", + lastUpdated: "2026-05-25T00:00:00.000Z", + }, + ], + }, + }, + null, + 2, + )}\n`, + ); + await Bun.write( + path.join(marketplaceRoot, "marketplace.json"), + `${JSON.stringify( + { + name: "claude-plugins-official", + owner: { name: "anthropic" }, + plugins: [ + { + name: "csharp-lsp", + version: "1.0.0", + source: "./csharp-lsp/1.0.0", + lspServers: { + "csharp-ls": { + command: "csharp-ls", + extensionToLanguage: { ".cs": "csharp" }, + }, + }, + }, + ], + }, + null, + 2, + )}\n`, + ); + + const whichSpy = vi + .spyOn(piUtils, "$which") + .mockImplementation(command => (command === "csharp-ls" ? "/usr/local/bin/csharp-ls" : null)); + + try { + await preloadPluginRoots(home, cwd); + + const config = loadConfig(cwd); + + expect(config.servers["csharp-ls"]?.resolvedCommand).toBe("/usr/local/bin/csharp-ls"); + expect(getServersForFile(config, path.join(cwd, "Program.cs")).map(([name]) => name)).toEqual(["csharp-ls"]); + expect(config.servers["csharp-ls"]?.rootMarkers).toEqual(["."]); + expect(whichSpy).toHaveBeenCalledWith("csharp-ls"); + } finally { + await preloadPluginRoots(path.join(tempDir.path(), "empty-home"), cwd); + tempDir.removeSync(); + } + }); it("rename_file applies LSP willRenameFiles edits and renames the file", async () => { const tempDir = TempDir.createSync("@omp-lsp-rename-file-"); try {