Merge pull request #1364 from can1357/farm/ccf5d9fd/csharp-lsp-plugin-doesn-t-work-with-omp-

fix(lsp): support config-only marketplace servers
This commit is contained in:
Can Bölük
2026-05-25 20:37:36 +03:00
committed by GitHub
5 changed files with 239 additions and 23 deletions
+1
View File
@@ -9,6 +9,7 @@
- Added `OMP_NO_WEBP` environment variable to disable WebP encoding in image resize, fixing HTTP 400 errors when attaching browser snapshots to vision models running on local llama.cpp (which uses STB library that lacks WebP support)
- Fixed loop mode submitting the next prompt while a background async-job delivery turn (idle flush) was still pending, which could cause the job result to be silently dropped and make the session appear to keep firing while work was ongoing ([#1294](https://github.com/can1357/oh-my-pi/issues/1294))
- 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)).
- Fixed JTD-to-JSON-Schema conversion treating user-named properties as nested JTD forms when their keys collided with JTD keywords like `ref`, which broke the built-in explore agent's output validator with `schema_violation: files.0.ref: must not be present` ([#1345](https://github.com/can1357/oh-my-pi/issues/1345))
- Fixed extension `ctx.ui.notify()` messages emitted during `session_start` being cleared before the first interactive render ([#1316](https://github.com/can1357/oh-my-pi/issues/1316)).
- Fixed append-only context mode not being recomputed after model switches — the mode was frozen at session construction time using the initial model's provider, so `provider.appendOnlyContext=auto` left append-only enabled after switching away from DeepSeek (or disabled after switching to DeepSeek) for the rest of the session
@@ -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<void> {
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)
+87 -22
View File
@@ -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<ServerConfig> {
extensionToLanguage?: unknown;
initializationOptions?: unknown;
}
interface NormalizedConfig {
servers: Record<string, Partial<ServerConfig>>;
servers: Record<string, RawServerConfig>;
idleTimeoutMs?: number;
}
@@ -42,12 +47,12 @@ function normalizeConfig(value: unknown): NormalizedConfig | null {
const rawServers = value.servers;
if (isRecord(rawServers)) {
return { servers: rawServers as Record<string, Partial<ServerConfig>>, idleTimeoutMs };
return { servers: rawServers as Record<string, RawServerConfig>, idleTimeoutMs };
}
const servers = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "idleTimeoutMs")) as Record<
string,
Partial<ServerConfig>
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>): 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<ServerConfig>): 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<ServerConfig>): Ser
args,
fileTypes,
rootMarkers,
...(initOptions ? { initOptions } : {}),
};
}
@@ -92,7 +109,7 @@ function readConfigFile(filePath: string): NormalizedConfig | null {
}
}
function coerceServerConfigs(servers: Record<string, Partial<ServerConfig>>): Record<string, ServerConfig> {
function coerceServerConfigs(servers: Record<string, RawServerConfig>): Record<string, ServerConfig> {
const result: Record<string, ServerConfig> = {};
for (const [name, config] of Object.entries(servers)) {
const normalized = normalizeServerConfig(name, config);
@@ -105,7 +122,7 @@ function coerceServerConfigs(servers: Record<string, Partial<ServerConfig>>): Re
function mergeServers(
base: Record<string, ServerConfig>,
overrides: Record<string, Partial<ServerConfig>>,
overrides: Record<string, RawServerConfig>,
): Record<string, ServerConfig> {
const merged: Record<string, ServerConfig> = { ...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) {
@@ -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", {
@@ -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"), "<Project />\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 {