@koriit/opencode-claude-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,405 @@
1
+ /**
2
+ * LSP injection from enabled Claude plugins into OpenCode's `cfg.lsp` record.
3
+ *
4
+ * Only runs when the global `allowLsp` toggle is on AND the user has not explicitly
5
+ * disabled all LSP via `cfg.lsp === false` (safe-by-default per §8).
6
+ *
7
+ * Source: each plugin's top-level `<installPath>/.lsp.json`.
8
+ * The file format mirrors the Claude LSP plugin schema (verified from claude-code
9
+ * `lspPluginIntegration.ts` and real installed LSP plugins):
10
+ *
11
+ * ```json
12
+ * {
13
+ * "<serverName>": {
14
+ * "command": "rust-analyzer",
15
+ * "args": ["--arg"],
16
+ * "extensionToLanguage": { ".rs": "rust" },
17
+ * "env": { "KEY": "value" },
18
+ * "initializationOptions": { ... }
19
+ * }
20
+ * }
21
+ * ```
22
+ *
23
+ * Mapping (Claude .lsp.json → OpenCode V1 cfg.lsp entry):
24
+ * - `command` (string) + `args` (array) → `command` (string array)
25
+ * - `extensionToLanguage` keys → `extensions` (array of file-extension strings)
26
+ * - `env` → `env`
27
+ * - `initializationOptions` or `settings` → `initialization`
28
+ * - `${CLAUDE_PLUGIN_ROOT}` resolved to `installPath` in command/args/env values
29
+ *
30
+ * SOURCE GAP NOTE: No real installed Claude plugin with LSP servers was available on
31
+ * this machine (the rust-analyzer-lsp@claude-plugins-official plugin has only README/LICENSE,
32
+ * no `.lsp.json`). The `.lsp.json` source convention is derived from claude-code source
33
+ * (`lspPluginIntegration.ts`) which explicitly checks `join(plugin.path, '.lsp.json')`.
34
+ * This is an Appendix-B–style open item: the mechanism is implemented and tested against
35
+ * a synthetic fixture, but real-world `.lsp.json` files have not been confirmed against
36
+ * a live installed plugin. Validate when an LSP plugin becomes available.
37
+ *
38
+ * `cfg.lsp === false` guard: if the user explicitly disabled all LSP servers, the bridge
39
+ * injects nothing and returns immediately (§6.5 — do not override user intent). This only
40
+ * skips LSP; commands, agents, skills, and MCP still inject as normal.
41
+ *
42
+ * Naming: base name `<plugin>-<server>`, then apply the §7 NameAllocator collision rules
43
+ * vs existing `cfg.lsp` keys (when `cfg.lsp` is an object).
44
+ */
45
+
46
+ import fs from "node:fs/promises"
47
+ import path from "node:path"
48
+ import type { Config } from "@opencode-ai/plugin"
49
+ import { NameAllocator, splitPluginId } from "./naming.js"
50
+ import type { Logger } from "./logger.js"
51
+ import type { ClaudePlugin } from "./types.js"
52
+
53
+ // ── OpenCode V1 LSP entry shape ────────────────────────────────────────────────
54
+
55
+ /**
56
+ * OpenCode V1 LSP entry shape (verified against packages/core/src/v1/config/lsp.ts).
57
+ *
58
+ * `cfg.lsp[name]` can be `{ disabled: true }` or a live server definition.
59
+ * We only ever write live entries; we never write `{ disabled: true }`.
60
+ */
61
+ export interface LspEntry {
62
+ command: string[]
63
+ extensions?: string[]
64
+ disabled?: boolean
65
+ env?: Record<string, string>
66
+ initialization?: Record<string, unknown>
67
+ }
68
+
69
+ // ── Config shape guard ────────────────────────────────────────────────────────
70
+
71
+ interface InjectableLspConfig {
72
+ lsp?: false | true | Record<string, LspEntry> | undefined
73
+ }
74
+
75
+ /**
76
+ * Normalize `cfg.lsp` to a mutable object we can extend.
77
+ *
78
+ * Respects `cfg.lsp === false` — callers MUST check before calling this.
79
+ * `true` and `undefined` are normalized to `{}`.
80
+ * When already an object, returns it as-is.
81
+ */
82
+ function guardLspConfig(cfg: InjectableLspConfig): Record<string, LspEntry> {
83
+ if (cfg.lsp === false) {
84
+ // Caller must not reach here; guard is defensive.
85
+ cfg.lsp = {}
86
+ } else if (cfg.lsp === undefined || cfg.lsp === true) {
87
+ cfg.lsp = {}
88
+ }
89
+ return cfg.lsp as Record<string, LspEntry>
90
+ }
91
+
92
+ // ── Claude .lsp.json schema ───────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Claude plugin LSP server definition as read from `.lsp.json`.
96
+ * Schema derived from claude-code `LspServerConfigSchema` (src/utils/plugins/schemas.ts).
97
+ */
98
+ interface ClaudeLspServer {
99
+ command?: string
100
+ args?: string[]
101
+ extensionToLanguage?: Record<string, string>
102
+ env?: Record<string, string>
103
+ initializationOptions?: unknown
104
+ settings?: unknown
105
+ transport?: string
106
+ workspaceFolder?: string
107
+ [key: string]: unknown
108
+ }
109
+
110
+ // ── Resolution helpers ────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Resolve `${CLAUDE_PLUGIN_ROOT}` in a string to the absolute `installPath`.
114
+ */
115
+ function resolvePluginRoot(text: string, installPath: string): string {
116
+ return text.replaceAll("${CLAUDE_PLUGIN_ROOT}", installPath)
117
+ }
118
+
119
+ /**
120
+ * Resolve `${CLAUDE_PLUGIN_ROOT}` in all string values of a record.
121
+ *
122
+ * Non-string values (e.g. a numeric env value from a loose JSON file) are
123
+ * silently dropped rather than reaching `String.prototype.replaceAll` and
124
+ * throwing a TypeError that would kill the whole hook run (§10).
125
+ */
126
+ function resolveRecordValues(
127
+ record: Record<string, unknown>,
128
+ installPath: string,
129
+ ): Record<string, string> {
130
+ const out: Record<string, string> = {}
131
+ for (const [k, v] of Object.entries(record)) {
132
+ if (typeof v === "string") out[k] = resolvePluginRoot(v, installPath)
133
+ // Non-string values are dropped — not valid in the target string-record fields (env).
134
+ }
135
+ return out
136
+ }
137
+
138
+ // ── Claude → OpenCode mapping ─────────────────────────────────────────────────
139
+
140
+ /** Discriminated result from {@link mapClaudeLspServer}. */
141
+ export type MapLspResult =
142
+ | { ok: true; entry: LspEntry }
143
+ | { ok: false; reason: "missing-command" | "socket-transport" | "no-extensions" }
144
+
145
+ /**
146
+ * Map a Claude `.lsp.json` server entry to an OpenCode V1 LSP entry.
147
+ *
148
+ * - `command` (string) + `args` (array) → `command` (string array)
149
+ * - `extensionToLanguage` keys → `extensions` (file extension strings, required)
150
+ * - `env` → `env` (with `${CLAUDE_PLUGIN_ROOT}` resolved)
151
+ * - `initializationOptions` or `settings` → `initialization` (best-effort)
152
+ * - `${CLAUDE_PLUGIN_ROOT}` resolved in command, args, env values
153
+ * - `workspaceFolder` is not mapped (no equivalent in OpenCode V1 LSP entry shape)
154
+ *
155
+ * Returns a discriminated result so callers emit precise per-reason warnings. The
156
+ * three rejection reasons are:
157
+ * - `"missing-command"`: no `command` string field
158
+ * - `"socket-transport"`: `transport: "socket"` is unsupported by OpenCode's LSP layer
159
+ * - `"no-extensions"`: no `.`-prefixed keys in `extensionToLanguage`; OpenCode V1
160
+ * requires `extensions` for any non-builtin cfg.lsp key
161
+ */
162
+ export function mapClaudeLspServer(
163
+ server: ClaudeLspServer,
164
+ installPath: string,
165
+ ): MapLspResult {
166
+ if (!server.command || typeof server.command !== "string") {
167
+ return { ok: false, reason: "missing-command" }
168
+ }
169
+
170
+ // OpenCode V1 LSP has no socket transport — silently misbehaves if injected as stdio.
171
+ if (server.transport === "socket") {
172
+ return { ok: false, reason: "socket-transport" }
173
+ }
174
+
175
+ const cmd = resolvePluginRoot(server.command, installPath)
176
+ const args = Array.isArray(server.args)
177
+ ? server.args.map((a) =>
178
+ typeof a === "string" ? resolvePluginRoot(a, installPath) : String(a),
179
+ )
180
+ : []
181
+
182
+ const entry: LspEntry = { command: [cmd, ...args] }
183
+
184
+ // Extract extensions from extensionToLanguage keys. OpenCode V1 REQUIRES `extensions`
185
+ // for any non-builtin cfg.lsp key; all bridge-injected names are custom by construction.
186
+ if (server.extensionToLanguage && typeof server.extensionToLanguage === "object") {
187
+ const keys = Object.keys(server.extensionToLanguage).filter(
188
+ (k) => typeof k === "string" && k.startsWith("."),
189
+ )
190
+ if (keys.length > 0) entry.extensions = keys
191
+ }
192
+
193
+ if (!entry.extensions || entry.extensions.length === 0) {
194
+ return { ok: false, reason: "no-extensions" }
195
+ }
196
+
197
+ if (server.env && typeof server.env === "object") {
198
+ entry.env = resolveRecordValues(server.env as Record<string, unknown>, installPath)
199
+ }
200
+
201
+ // initializationOptions takes precedence over settings (both are best-effort).
202
+ const initSource =
203
+ server.initializationOptions !== undefined
204
+ ? server.initializationOptions
205
+ : server.settings !== undefined
206
+ ? server.settings
207
+ : undefined
208
+
209
+ if (initSource !== undefined && typeof initSource === "object" && initSource !== null) {
210
+ entry.initialization = initSource as Record<string, unknown>
211
+ }
212
+
213
+ return { ok: true, entry }
214
+ }
215
+
216
+ // ── Per-plugin injection ──────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Read and parse `<installPath>/.lsp.json`. Returns the parsed servers record or
220
+ * `null` if the file is absent (silent) or unreadable (warns and skips).
221
+ */
222
+ async function readLspJson(
223
+ installPath: string,
224
+ pluginId: string,
225
+ logger: Logger,
226
+ ): Promise<Record<string, ClaudeLspServer> | null> {
227
+ const lspPath = path.join(installPath, ".lsp.json")
228
+ let raw: string
229
+ try {
230
+ raw = await fs.readFile(lspPath, "utf8")
231
+ } catch (err) {
232
+ const code = (err as NodeJS.ErrnoException).code
233
+ if (code === "ENOENT") return null // no LSP for this plugin — silent
234
+ logger.warn(
235
+ `could not read ".lsp.json" from plugin "${pluginId}" (${err instanceof Error ? err.message : String(err)}); skipping LSP for this plugin`,
236
+ )
237
+ return null
238
+ }
239
+
240
+ try {
241
+ const parsed = JSON.parse(raw) as unknown
242
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
243
+ logger.warn(
244
+ `".lsp.json" from plugin "${pluginId}" is not a JSON object; skipping LSP for this plugin`,
245
+ )
246
+ return null
247
+ }
248
+ return parsed as Record<string, ClaudeLspServer>
249
+ } catch (err) {
250
+ logger.warn(
251
+ `could not parse ".lsp.json" from plugin "${pluginId}" (${err instanceof Error ? err.message : String(err)}); skipping LSP for this plugin`,
252
+ )
253
+ return null
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Inject LSP servers from one plugin's `.lsp.json` into `lspCfg`.
259
+ */
260
+ async function injectPluginLsp(
261
+ plugin: ClaudePlugin,
262
+ lspCfg: Record<string, LspEntry>,
263
+ allocator: NameAllocator,
264
+ summary: LspInjectionSummary,
265
+ logger: Logger,
266
+ ): Promise<void> {
267
+ const servers = await readLspJson(plugin.installPath, plugin.id, logger)
268
+ if (servers === null) return
269
+
270
+ const pluginPart = splitPluginId(plugin.id).plugin
271
+
272
+ for (const [serverName, serverDef] of Object.entries(servers)) {
273
+ if (typeof serverDef !== "object" || serverDef === null || Array.isArray(serverDef)) {
274
+ logger.warn(
275
+ `LSP server "${serverName}" in plugin "${plugin.id}" is not a valid object; skipping`,
276
+ )
277
+ continue
278
+ }
279
+
280
+ const result = mapClaudeLspServer(serverDef, plugin.installPath)
281
+ if (!result.ok) {
282
+ // Precise per-reason warning — the discriminated result prevents misclassification
283
+ // if new rejection reasons are added to mapClaudeLspServer later.
284
+ if (result.reason === "socket-transport") {
285
+ logger.warn(
286
+ `LSP server "${serverName}" in plugin "${plugin.id}" uses socket transport which is not supported by the bridge; skipping`,
287
+ )
288
+ } else if (result.reason === "missing-command") {
289
+ logger.warn(
290
+ `LSP server "${serverName}" in plugin "${plugin.id}" is missing required "command" field; skipping`,
291
+ )
292
+ } else {
293
+ // "no-extensions": missing or empty extensionToLanguage — OpenCode requires extensions for custom servers.
294
+ logger.warn(
295
+ `LSP server "${serverName}" in plugin "${plugin.id}" has no "extensionToLanguage" mapping; OpenCode requires extensions for custom LSP servers — skipping`,
296
+ )
297
+ }
298
+ continue
299
+ }
300
+
301
+ // Base name is always "<plugin>-<server>" per §6.5 — intentional prefix.
302
+ // On collision the NameAllocator ladder escalates from this already-prefixed base.
303
+ // pluginPart is hoisted outside the loop (splitPluginId is loop-invariant).
304
+ const baseName = `${pluginPart}-${serverName}`
305
+ const { name, renamed } = allocator.claim(plugin.id, baseName)
306
+
307
+ lspCfg[name] = result.entry
308
+ summary.servers++
309
+ if (renamed) summary.renamed++
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Count LSP servers in a plugin's `.lsp.json` without any validation or warnings.
315
+ * Used only for the policy-gate summary when `allowLsp` is off.
316
+ * Returns 0 if the file is absent, unreadable, or malformed.
317
+ */
318
+ async function countLspServersQuiet(installPath: string): Promise<number> {
319
+ const lspPath = path.join(installPath, ".lsp.json")
320
+ try {
321
+ const raw = await fs.readFile(lspPath, "utf8")
322
+ const parsed = JSON.parse(raw) as unknown
323
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
324
+ return Object.keys(parsed as object).length
325
+ }
326
+ return 0
327
+ } catch {
328
+ return 0
329
+ }
330
+ }
331
+
332
+ // ── Public API ────────────────────────────────────────────────────────────────
333
+
334
+ /** Summary counters for LSP injection collected across all plugins. */
335
+ export interface LspInjectionSummary {
336
+ servers: number
337
+ renamed: number
338
+ skippedPolicy: number
339
+ /** True when cfg.lsp === false and injection was skipped per user intent. */
340
+ skippedUserDisabled: boolean
341
+ }
342
+
343
+ /**
344
+ * Inject LSP servers from all `plugins` into the mutable `cfg`.
345
+ *
346
+ * Guards:
347
+ * 1. When `allowLsp` is false, logs a policy summary and injects nothing.
348
+ * 2. When `cfg.lsp === false`, the user has explicitly disabled all LSP — inject
349
+ * nothing, but this ONLY skips LSP (commands/agents/skills/MCP still inject).
350
+ *
351
+ * Builds one `NameAllocator` seeded with existing `cfg.lsp` keys (when `cfg.lsp`
352
+ * is already an object). Processes plugins in the order given (caller passes
353
+ * sorted-by-id order).
354
+ */
355
+ export async function injectLsp(
356
+ plugins: ClaudePlugin[],
357
+ cfg: Config,
358
+ allowLsp: boolean,
359
+ logger: Logger,
360
+ ): Promise<LspInjectionSummary> {
361
+ const summary: LspInjectionSummary = {
362
+ servers: 0,
363
+ renamed: 0,
364
+ skippedPolicy: 0,
365
+ skippedUserDisabled: false,
366
+ }
367
+
368
+ const mutableCfg = cfg as unknown as InjectableLspConfig
369
+
370
+ // Guard 1: §6.5 — if user set cfg.lsp === false, respect their intent. Skip LSP only.
371
+ if (mutableCfg.lsp === false) {
372
+ summary.skippedUserDisabled = true
373
+ logger.info("skipped LSP injection (cfg.lsp is false — user disabled all LSP)")
374
+ return summary
375
+ }
376
+
377
+ if (!allowLsp) {
378
+ // Policy gate: count potential servers without per-entry validation — a user who
379
+ // opted out of LSP should not see LSP-config warnings from plugins they never enabled.
380
+ let totalServers = 0
381
+ for (const plugin of plugins) {
382
+ totalServers += await countLspServersQuiet(plugin.installPath)
383
+ }
384
+ summary.skippedPolicy = totalServers
385
+ if (totalServers > 0) {
386
+ logger.info(`skipped ${totalServers} LSP server(s) (policy: allowLsp is off)`)
387
+ }
388
+ return summary
389
+ }
390
+
391
+ if (plugins.length === 0) return summary
392
+
393
+ // Normalize cfg.lsp to an object.
394
+ const lspCfg = guardLspConfig(mutableCfg)
395
+
396
+ // Seed the allocator with existing cfg.lsp keys.
397
+ const existingLsp = new Set<string>(Object.keys(lspCfg))
398
+ const allocator = new NameAllocator(existingLsp)
399
+
400
+ for (const plugin of plugins) {
401
+ await injectPluginLsp(plugin, lspCfg, allocator, summary, logger)
402
+ }
403
+
404
+ return summary
405
+ }