@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.
package/src/index.ts ADDED
@@ -0,0 +1,158 @@
1
+ import os from "node:os"
2
+ import type { Plugin, PluginModule } from "@opencode-ai/plugin"
3
+ import { parseBridgeConfig } from "./config.js"
4
+ import { injectCommandsAndAgents } from "./inject.js"
5
+ import { injectSkills } from "./skill-inject.js"
6
+ import { injectMcp } from "./mcp-inject.js"
7
+ import { injectLsp } from "./lsp-inject.js"
8
+ import { createLogger } from "./logger.js"
9
+ import { listClaudePlugins, selectEnabledPlugins } from "./selection.js"
10
+ import { collectExistingSkillNames } from "./skill-scan.js"
11
+ import { checkVersion, fetchOpencodeVersion } from "./version.js"
12
+
13
+ /**
14
+ * Parse an environment-variable value as a boolean, matching the set of truthy
15
+ * strings that Effect's `Config.boolean` accepts (used by OpenCode's `RuntimeFlags`):
16
+ * `1`, `true`, `yes`, `on` → `true`; everything else including `undefined` → `false`.
17
+ * Comparison is case-insensitive.
18
+ *
19
+ * This ensures the bridge's skip-decision mirrors OpenCode's own flag evaluation —
20
+ * a mismatch would cause the bridge to scan dirs that OpenCode skips (or vice versa),
21
+ * producing spurious collision-renames.
22
+ */
23
+ export function parseBooleanEnv(value: string | undefined): boolean {
24
+ if (value === undefined) return false
25
+ switch (value.toLowerCase()) {
26
+ case "1":
27
+ case "true":
28
+ case "yes":
29
+ case "on":
30
+ return true
31
+ default:
32
+ return false
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The OpenCode plugin factory. Bridge options arrive as the tuple's second element
38
+ * (`["opencode-claude-bridge", { ...options }]`) and are captured in the `config` hook's
39
+ * closure — the hook signature carries only the config object (verified against the
40
+ * OpenCode source). The hook resolves the set of enabled Claude plugins and injects
41
+ * their commands, agents, skills, and (opt-in) MCP and LSP servers into the shared,
42
+ * mutable config.
43
+ */
44
+ export const server: Plugin = async (_input, options) => {
45
+ const { config: bridge, warnings } = parseBridgeConfig(options)
46
+
47
+ // `diagnosticsFired` is set to true by the config hook when any warning fires.
48
+ // The chat.message hook reads it to decide whether to show the one-time toast.
49
+ // Lives in the factory closure so both hooks share the same flag.
50
+ let diagnosticsFired = false
51
+ let toastShown = false
52
+
53
+ return {
54
+ config: async (cfg) => {
55
+ const logger = createLogger(bridge.strict)
56
+ try {
57
+ // Replay parse-time validation warnings (strict-promotable).
58
+ for (const w of warnings) logger.warn(w)
59
+
60
+ // §9 version-compat advisory (always soft — still attempts injection).
61
+ const version = await fetchOpencodeVersion(_input.serverUrl)
62
+ checkVersion(version, logger)
63
+
64
+ // §5 mirror-claude resolution.
65
+ const all = await listClaudePlugins(_input.$, logger)
66
+ if (all === null) return // CLI missing/failed — already warned; inject nothing.
67
+
68
+ const selected = selectEnabledPlugins(all, bridge, _input.directory)
69
+ const ids = selected.map((p) => p.id).join(", ")
70
+ logger.info(
71
+ `resolved ${selected.length} enabled Claude plugin(s)${ids ? `: ${ids}` : ""}`,
72
+ )
73
+
74
+ // §6.1 commands, §6.2 agents — inline injection into the shared cfg.
75
+ const cmdAgentSummary = await injectCommandsAndAgents(selected, cfg, logger)
76
+
77
+ // §6.3 skills — cfg.skills.paths injection (with bridge-cache copy on collision).
78
+ const home = os.homedir()
79
+ const existingSkillNames = await collectExistingSkillNames({
80
+ home,
81
+ projectDir: _input.directory,
82
+ skillsPaths: (cfg as unknown as { skills?: { paths?: string[] } }).skills?.paths,
83
+ // Mirror OpenCode's RuntimeFlags so the bridge scans the same dirs OpenCode will.
84
+ disableExternalSkills: parseBooleanEnv(process.env["OPENCODE_DISABLE_EXTERNAL_SKILLS"]),
85
+ disableClaudeCodeSkills:
86
+ parseBooleanEnv(process.env["OPENCODE_DISABLE_CLAUDE_CODE"]) ||
87
+ parseBooleanEnv(process.env["OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"]),
88
+ })
89
+ const skillSummary = await injectSkills(selected, cfg, existingSkillNames, {
90
+ home,
91
+ projectDir: _input.directory,
92
+ cacheRoot: process.env["OPENCODE_CLAUDE_BRIDGE_CACHE_ROOT"],
93
+ }, logger)
94
+
95
+ // §6.4 MCP — cfg.mcp injection (opt-in via allowMcp).
96
+ const mcpSummary = await injectMcp(selected, cfg, bridge.allowMcp, logger)
97
+
98
+ // §6.5 LSP — cfg.lsp injection (opt-in via allowLsp; respects cfg.lsp === false).
99
+ const lspSummary = await injectLsp(selected, cfg, bridge.allowLsp, logger)
100
+
101
+ // §10 concise per-run summary.
102
+ const renamed = cmdAgentSummary.renamed + skillSummary.renamed + mcpSummary.renamed + lspSummary.renamed
103
+ const summaryParts: string[] = [
104
+ `injected ${cmdAgentSummary.commands} command(s), ${cmdAgentSummary.agents} agent(s), ${skillSummary.skills} skill(s), ${mcpSummary.servers} MCP server(s), ${lspSummary.servers} LSP server(s)`,
105
+ ]
106
+ if (renamed > 0) summaryParts.push(`renamed ${renamed} (collision)`)
107
+ if (mcpSummary.skippedPolicy > 0) summaryParts.push(`skipped ${mcpSummary.skippedPolicy} MCP (policy)`)
108
+ if (lspSummary.skippedPolicy > 0) summaryParts.push(`skipped ${lspSummary.skippedPolicy} LSP (policy)`)
109
+ logger.info(summaryParts.join("; "))
110
+ } catch (err) {
111
+ // Strict mode: surface a hard failure. Non-strict: the hook must never throw,
112
+ // so OpenCode still starts (design §10).
113
+ if (bridge.strict) throw err
114
+ const detail = err instanceof Error ? err.message : String(err)
115
+ // Strict already re-threw above, so this soft warning only runs in non-strict mode;
116
+ // route it through the logger so all bridge output shares one format.
117
+ logger.warn(`unexpected error during config injection (${detail}); injected nothing this run`, {
118
+ fatalInStrict: false,
119
+ })
120
+ } finally {
121
+ // Record whether the config hook produced any warnings. Checked by chat.message
122
+ // so the toast fires on the user's first interaction rather than at config time
123
+ // (the TUI's event subscription is not yet guaranteed at config-hook execution).
124
+ if (logger.hadWarnings()) diagnosticsFired = true
125
+ }
126
+ },
127
+
128
+ "chat.message": async () => {
129
+ // Show a one-time toast on the first chat message if any warnings were emitted
130
+ // during the config hook. The toast is best-effort — a failure must never throw.
131
+ if (!diagnosticsFired || toastShown) return
132
+ toastShown = true
133
+ try {
134
+ await _input.client.tui.showToast({
135
+ body: {
136
+ variant: "warning",
137
+ message: "opencode-claude-bridge encountered issues — run with --print-logs for details",
138
+ },
139
+ })
140
+ } catch {
141
+ // Best-effort only: if the TUI is not available (e.g. non-TUI mode) ignore silently.
142
+ }
143
+ },
144
+ }
145
+ }
146
+
147
+ /** Stable plugin id — required for `file://`-loaded plugins and used as the bridge's identity. */
148
+ export const PLUGIN_ID = "opencode-claude-bridge"
149
+
150
+ /**
151
+ * The OpenCode V1 plugin module. OpenCode reads `mod.default`: it must be an object carrying a
152
+ * `server` factory (and, for path-loaded plugins, an `id`). A bare default-exported *function*
153
+ * is not recognized as V1 and falls through to OpenCode's legacy export scan, which rejects any
154
+ * non-function export. Because this object is a valid V1 default export, that scan never runs —
155
+ * which is why the named `server` / `PLUGIN_ID` exports above are safe to keep for tests.
156
+ */
157
+ const plugin: PluginModule = { id: PLUGIN_ID, server }
158
+ export default plugin
package/src/inject.ts ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Commands and agents injection from enabled Claude plugins into the shared OpenCode config.
3
+ *
4
+ * Each enabled plugin is scanned for:
5
+ * - `<installPath>/commands/**\/*.md` → injected into `cfg.command`
6
+ * - `<installPath>/agents/*.md` → injected into `cfg.agent`
7
+ *
8
+ * Names are allocated via the §7 NameAllocator seeded with cfg keys ∪ built-ins, processed
9
+ * in the sorted-by-id order that `selectEnabledPlugins` guarantees so the first claimant
10
+ * of any bare name wins deterministically.
11
+ *
12
+ * Traceability: every injected item's `description` is suffixed with `[<plugin-id>]` so a
13
+ * renamed item can be traced back to its source plugin.
14
+ */
15
+
16
+ import fs from "node:fs/promises"
17
+ import path from "node:path"
18
+ import type { Config } from "@opencode-ai/plugin"
19
+ import { parseFrontmatter, FRONTMATTER_PARSE_ERROR } from "./frontmatter.js"
20
+ import { NameAllocator } from "./naming.js"
21
+ import { BUILTIN_AGENT_NAMES, BUILTIN_COMMAND_NAMES } from "./opencode-builtins.js"
22
+ import type { Logger } from "./logger.js"
23
+ import type { ClaudePlugin } from "./types.js"
24
+
25
+ // ── Regex sentinels for unsupported Claude command placeholders ───────────────
26
+ // @file references: the token after @ must contain a `.` or `/` to look like a file path.
27
+ // This avoids false positives on prose like `see @user above` (bare word, no dot or slash)
28
+ // while still catching `@somefile.txt`, `@./relative`, `@/absolute`, and `@path/to/file`.
29
+ // No `/g` flag — these regexes are only used with `.test()` for detection; the `/g` flag
30
+ // makes `.test()` stateful (advances lastIndex between calls), which is a foot-gun.
31
+ const FILE_REF_RE = /(?<![\w`])@[^\s`]*[./][^\s`]*/
32
+ // !`cmd` shell-expansion placeholders.
33
+ const SHELL_EXPAND_RE = /!`([^`]+)`/
34
+
35
+ // ── OpenCode V1 injectable types ──────────────────────────────────────────────
36
+
37
+ /** The V1 shape accepted by `cfg.command[name]`. Only fields defined in ConfigCommandV1.Info. */
38
+ export interface CommandEntry {
39
+ template: string
40
+ description?: string
41
+ agent?: string
42
+ model?: string
43
+ variant?: string
44
+ subtask?: boolean
45
+ }
46
+
47
+ /** The V1 shape accepted by `cfg.agent[name]`. No `permission` field is emitted. */
48
+ export interface AgentEntry {
49
+ description?: string
50
+ mode?: "subagent" | "primary" | "all"
51
+ prompt?: string
52
+ model?: string
53
+ variant?: string
54
+ temperature?: number
55
+ top_p?: number
56
+ steps?: number
57
+ hidden?: boolean
58
+ color?: string
59
+ }
60
+
61
+ /**
62
+ * A narrowed view of the OpenCode `Config` object that exposes only the two families
63
+ * injected by this session. We use the SDK `Config` type in the public API and cast
64
+ * internally, since we are intentionally mutating the shared runtime config object.
65
+ *
66
+ * The `Config` type from `@opencode-ai/plugin` has complex Effect/Schema-derived types
67
+ * for `command` and `agent` that don't accept our V1 injection shapes at compile time, but
68
+ * the runtime shape is a plain mutable object — verified empirically (Appendix A).
69
+ */
70
+ // Kept for unit-test type assertions.
71
+ export interface InjectableConfig {
72
+ command?: Record<string, CommandEntry> | undefined
73
+ agent?: Record<string, AgentEntry> | undefined
74
+ }
75
+
76
+ /** Summary counters collected across all plugins in one injection run. */
77
+ export interface InjectionSummary {
78
+ commands: number
79
+ agents: number
80
+ renamed: number
81
+ }
82
+
83
+ // ── File collection helpers ────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Recursively collect all `*.md` files under `dir`, ignoring inaccessible paths.
87
+ * Returned paths are absolute.
88
+ */
89
+ async function collectMdFiles(dir: string): Promise<string[]> {
90
+ const results: string[] = []
91
+ let entries: string[]
92
+ try {
93
+ entries = await fs.readdir(dir)
94
+ } catch {
95
+ return results
96
+ }
97
+ for (const name of entries) {
98
+ const full = path.join(dir, name)
99
+ let stat: Awaited<ReturnType<typeof fs.stat>>
100
+ try {
101
+ stat = await fs.stat(full)
102
+ } catch {
103
+ continue
104
+ }
105
+ if (stat.isDirectory()) {
106
+ const sub = await collectMdFiles(full)
107
+ results.push(...sub)
108
+ } else if (stat.isFile() && name.endsWith(".md")) {
109
+ results.push(full)
110
+ }
111
+ }
112
+ return results
113
+ }
114
+
115
+ // ── Command name derivation ────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Derive the bare command name from an absolute file path and the plugin's installPath.
119
+ *
120
+ * Mirrors OpenCode's `configEntryNameFromPath(path.relative(dir, item), ["command/", "commands/"])`:
121
+ * strips the `commands/` or `command/` prefix from the path relative to `installPath`,
122
+ * then removes the file extension. When no known prefix matches, falls back to
123
+ * `path.basename(rel)` exactly as OpenCode does — so the key is always byte-identical
124
+ * to what OpenCode would produce for the same file.
125
+ *
126
+ * For nested dirs under commands/ (e.g. `commands/foo/bar.md`) the result is `foo/bar`.
127
+ */
128
+ export function commandNameFromPath(installPath: string, filePath: string): string {
129
+ const rel = path.relative(installPath, filePath).replaceAll("\\", "/")
130
+ let candidate: string | undefined
131
+ for (const prefix of ["commands/", "command/"]) {
132
+ if (rel.startsWith(prefix)) {
133
+ candidate = rel.slice(prefix.length)
134
+ break
135
+ }
136
+ }
137
+ // OpenCode falls back to path.basename when no known prefix matches.
138
+ const base = candidate ?? path.basename(rel)
139
+ const ext = path.extname(base)
140
+ return ext.length ? base.slice(0, -ext.length) : base
141
+ }
142
+
143
+ /**
144
+ * Derive the bare agent name from an absolute file path and the plugin's installPath.
145
+ *
146
+ * Mirrors OpenCode's `configEntryNameFromPath(path.relative(dir, item), ["agent/", "agents/"])`.
147
+ * Agents are flat (`agents/*.md`), so names have no slashes in practice. Falls back to
148
+ * `path.basename` when no known prefix matches — same as OpenCode.
149
+ */
150
+ export function agentNameFromPath(installPath: string, filePath: string): string {
151
+ const rel = path.relative(installPath, filePath).replaceAll("\\", "/")
152
+ let candidate: string | undefined
153
+ for (const prefix of ["agents/", "agent/"]) {
154
+ if (rel.startsWith(prefix)) {
155
+ candidate = rel.slice(prefix.length)
156
+ break
157
+ }
158
+ }
159
+ const base = candidate ?? path.basename(rel)
160
+ const ext = path.extname(base)
161
+ return ext.length ? base.slice(0, -ext.length) : base
162
+ }
163
+
164
+ // ── Template processing ────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Resolve `${CLAUDE_PLUGIN_ROOT}` in a string to the absolute `installPath`.
168
+ * `$ARGUMENTS` and `$1..$n` pass through untouched (OpenCode supports them).
169
+ */
170
+ function resolvePluginRoot(text: string, installPath: string): string {
171
+ return text.replaceAll("${CLAUDE_PLUGIN_ROOT}", installPath)
172
+ }
173
+
174
+ /**
175
+ * Warn if the command template contains `@file` references or `` !`cmd` `` shell-expansion
176
+ * placeholders — these are not supported by OpenCode and are left verbatim (§10 passthrough).
177
+ */
178
+ function warnUnsupportedPlaceholders(template: string, commandName: string, pluginId: string, logger: Logger): void {
179
+ if (FILE_REF_RE.test(template)) {
180
+ logger.warn(
181
+ `command "${commandName}" from plugin "${pluginId}" uses @file references which are not supported by OpenCode — left verbatim`,
182
+ { fatalInStrict: false },
183
+ )
184
+ }
185
+ if (SHELL_EXPAND_RE.test(template)) {
186
+ logger.warn(
187
+ `command "${commandName}" from plugin "${pluginId}" uses !\`cmd\` shell expansion which is not supported by OpenCode — left verbatim`,
188
+ { fatalInStrict: false },
189
+ )
190
+ }
191
+ }
192
+
193
+ // ── Model string handling ─────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Return the model string only if it already looks like `provider/model`.
197
+ * Drop anything else rather than guessing the provider — conservative mapping per the
198
+ * architectural decisions for this session.
199
+ */
200
+ function modelIfMappable(model: unknown): string | undefined {
201
+ if (typeof model !== "string") return undefined
202
+ return model.includes("/") ? model : undefined
203
+ }
204
+
205
+ // ── Command injection for one plugin ─────────────────────────────────────────
206
+
207
+ /**
208
+ * Scan `<installPath>/commands/**\/*.md`, parse each file, and inject into `cfg.command`.
209
+ *
210
+ * @returns the number of commands successfully injected.
211
+ */
212
+ async function injectPluginCommands(
213
+ plugin: ClaudePlugin,
214
+ cfg: InjectableConfig,
215
+ allocator: NameAllocator,
216
+ summary: InjectionSummary,
217
+ logger: Logger,
218
+ ): Promise<void> {
219
+ const commandsDir = path.join(plugin.installPath, "commands")
220
+ const files = await collectMdFiles(commandsDir)
221
+ if (files.length === 0) return
222
+
223
+ // Guard: ensure cfg.command is a plain object before writing to it.
224
+ // `typeof null === "object"`, so null must be checked explicitly.
225
+ if (cfg.command === undefined || cfg.command === null || typeof cfg.command !== "object") {
226
+ cfg.command = {}
227
+ }
228
+
229
+ for (const filePath of files) {
230
+ let content: string
231
+ try {
232
+ content = await fs.readFile(filePath, "utf8")
233
+ } catch (err) {
234
+ logger.warn(
235
+ `could not read command file "${filePath}" from plugin "${plugin.id}" (${err instanceof Error ? err.message : String(err)}); skipping`,
236
+ )
237
+ continue
238
+ }
239
+
240
+ const bareName = commandNameFromPath(plugin.installPath, filePath)
241
+
242
+ const parsed = parseFrontmatter(content)
243
+ if (parsed === FRONTMATTER_PARSE_ERROR) {
244
+ // Opening fence found but never closed — malformed YAML, skip per §10.
245
+ logger.warn(
246
+ `command file "${filePath}" from plugin "${plugin.id}" has malformed frontmatter (unclosed --- fence); skipping`,
247
+ { fatalInStrict: false },
248
+ )
249
+ continue
250
+ }
251
+ if (parsed === null) {
252
+ // No frontmatter — treat the entire content as the template body.
253
+ const body = content.trim()
254
+ if (!body) continue
255
+ const { name, renamed } = allocator.claim(plugin.id, bareName)
256
+ const template = resolvePluginRoot(body, plugin.installPath)
257
+ warnUnsupportedPlaceholders(template, name, plugin.id, logger)
258
+ const entry: CommandEntry = {
259
+ template,
260
+ description: `${bareName} [${plugin.id}]`,
261
+ }
262
+ cfg.command[name] = entry
263
+ summary.commands++
264
+ if (renamed) summary.renamed++
265
+ continue
266
+ }
267
+
268
+ const { data, body } = parsed
269
+ if (!body) {
270
+ logger.warn(
271
+ `command file "${filePath}" from plugin "${plugin.id}" has no body; skipping`,
272
+ { fatalInStrict: false },
273
+ )
274
+ continue
275
+ }
276
+
277
+ const { name, renamed } = allocator.claim(plugin.id, bareName)
278
+
279
+ const rawDescription = typeof data["description"] === "string" ? data["description"] : undefined
280
+ const tracedDescription = rawDescription
281
+ ? `${resolvePluginRoot(rawDescription, plugin.installPath)} [${plugin.id}]`
282
+ : `${bareName} [${plugin.id}]`
283
+
284
+ const template = resolvePluginRoot(body, plugin.installPath)
285
+ warnUnsupportedPlaceholders(template, name, plugin.id, logger)
286
+
287
+ const entry: CommandEntry = {
288
+ template,
289
+ description: tracedDescription,
290
+ }
291
+
292
+ const agent = data["agent"]
293
+ if (typeof agent === "string") entry.agent = agent
294
+
295
+ const model = modelIfMappable(data["model"])
296
+ if (model !== undefined) entry.model = model
297
+
298
+ const variant = data["variant"]
299
+ if (typeof variant === "string") entry.variant = variant
300
+
301
+ const subtask = data["subtask"]
302
+ if (typeof subtask === "boolean") entry.subtask = subtask
303
+
304
+ cfg.command[name] = entry
305
+ summary.commands++
306
+ if (renamed) summary.renamed++
307
+ }
308
+ }
309
+
310
+ // ── Agent injection for one plugin ───────────────────────────────────────────
311
+
312
+ /**
313
+ * Scan `<installPath>/agents/*.md`, parse each file, and inject into `cfg.agent`.
314
+ */
315
+ async function injectPluginAgents(
316
+ plugin: ClaudePlugin,
317
+ cfg: InjectableConfig,
318
+ allocator: NameAllocator,
319
+ summary: InjectionSummary,
320
+ logger: Logger,
321
+ ): Promise<void> {
322
+ const agentsDir = path.join(plugin.installPath, "agents")
323
+ const files = await collectMdFiles(agentsDir)
324
+ if (files.length === 0) return
325
+
326
+ // Guard: ensure cfg.agent is a plain object before writing to it.
327
+ // `typeof null === "object"`, so null must be checked explicitly.
328
+ if (cfg.agent === undefined || cfg.agent === null || typeof cfg.agent !== "object") {
329
+ cfg.agent = {}
330
+ }
331
+
332
+ for (const filePath of files) {
333
+ // Agents are flat: agents/*.md only. Ignore any nested files (Claude spec).
334
+ const rel = path.relative(agentsDir, filePath).replaceAll("\\", "/")
335
+ if (rel.includes("/")) continue
336
+
337
+ let content: string
338
+ try {
339
+ content = await fs.readFile(filePath, "utf8")
340
+ } catch (err) {
341
+ logger.warn(
342
+ `could not read agent file "${filePath}" from plugin "${plugin.id}" (${err instanceof Error ? err.message : String(err)}); skipping`,
343
+ )
344
+ continue
345
+ }
346
+
347
+ const bareName = agentNameFromPath(plugin.installPath, filePath)
348
+
349
+ const parsed = parseFrontmatter(content)
350
+ if (parsed === FRONTMATTER_PARSE_ERROR) {
351
+ // Opening fence found but never closed — malformed YAML, skip per §10.
352
+ logger.warn(
353
+ `agent file "${filePath}" from plugin "${plugin.id}" has malformed frontmatter (unclosed --- fence); skipping`,
354
+ { fatalInStrict: false },
355
+ )
356
+ continue
357
+ }
358
+ if (parsed === null) {
359
+ // No frontmatter — treat the entire content as the prompt body.
360
+ const body = content.trim()
361
+ if (!body) continue
362
+ const { name, renamed } = allocator.claim(plugin.id, bareName)
363
+ const prompt = resolvePluginRoot(body, plugin.installPath)
364
+ const entry: AgentEntry = {
365
+ description: `${bareName} [${plugin.id}]`,
366
+ mode: "subagent",
367
+ prompt,
368
+ }
369
+ cfg.agent[name] = entry
370
+ summary.agents++
371
+ if (renamed) summary.renamed++
372
+ continue
373
+ }
374
+
375
+ const { data, body } = parsed
376
+
377
+ // Consistency with commands (§10): a body-less agent file is almost certainly broken.
378
+ if (!body) {
379
+ logger.warn(
380
+ `agent file "${filePath}" from plugin "${plugin.id}" has no body; skipping`,
381
+ { fatalInStrict: false },
382
+ )
383
+ continue
384
+ }
385
+
386
+ const { name, renamed } = allocator.claim(plugin.id, bareName)
387
+
388
+ const rawDescription = typeof data["description"] === "string" ? data["description"] : undefined
389
+ const tracedDescription = rawDescription
390
+ ? `${resolvePluginRoot(rawDescription, plugin.installPath)} [${plugin.id}]`
391
+ : `${bareName} [${plugin.id}]`
392
+
393
+ const prompt = resolvePluginRoot(body, plugin.installPath)
394
+
395
+ const entry: AgentEntry = {
396
+ description: tracedDescription,
397
+ mode: "subagent",
398
+ prompt,
399
+ }
400
+
401
+ // Map `mode` only for the known values; drop anything unrecognized.
402
+ const modeRaw = data["mode"]
403
+ if (modeRaw === "subagent" || modeRaw === "primary" || modeRaw === "all") {
404
+ entry.mode = modeRaw
405
+ }
406
+
407
+ const model = modelIfMappable(data["model"])
408
+ if (model !== undefined) entry.model = model
409
+
410
+ const variant = data["variant"]
411
+ if (typeof variant === "string") entry.variant = variant
412
+
413
+ const temperature = data["temperature"]
414
+ if (typeof temperature === "number") entry.temperature = temperature
415
+
416
+ const top_p = data["top_p"]
417
+ if (typeof top_p === "number") entry.top_p = top_p
418
+
419
+ const steps = data["steps"]
420
+ if (typeof steps === "number" && Number.isInteger(steps) && steps > 0) entry.steps = steps
421
+
422
+ const hidden = data["hidden"]
423
+ if (typeof hidden === "boolean") entry.hidden = hidden
424
+
425
+ const color = data["color"]
426
+ if (typeof color === "string") entry.color = color
427
+
428
+ cfg.agent[name] = entry
429
+ summary.agents++
430
+ if (renamed) summary.renamed++
431
+ }
432
+ }
433
+
434
+ // ── Public API ────────────────────────────────────────────────────────────────
435
+
436
+ /**
437
+ * Inject commands and agents from all `plugins` into the mutable `cfg`.
438
+ *
439
+ * Builds one `NameAllocator` per family (commands, agents), seeded with the union of
440
+ * existing cfg keys and the OpenCode built-in name lists. Processes plugins in the
441
+ * order given by `plugins` (callers must pass the sorted-by-id order from
442
+ * `selectEnabledPlugins`). Returns a summary of what was injected.
443
+ *
444
+ * Accepts the SDK `Config` type and casts it to `InjectableConfig` for internal use.
445
+ * The runtime shape is a plain mutable object (verified empirically, Appendix A).
446
+ *
447
+ * A file that fails to read or parse is skipped with a warning; the hook does not throw
448
+ * unless `strict` mode is on and the logger re-throws (design §10).
449
+ */
450
+ export async function injectCommandsAndAgents(
451
+ plugins: ClaudePlugin[],
452
+ cfg: Config,
453
+ logger: Logger,
454
+ ): Promise<InjectionSummary> {
455
+ const mutableCfg = cfg as unknown as InjectableConfig
456
+ const summary: InjectionSummary = { commands: 0, agents: 0, renamed: 0 }
457
+
458
+ if (plugins.length === 0) return summary
459
+
460
+ // Seed the command allocator with existing cfg keys ∪ built-in command names.
461
+ const existingCommands = new Set<string>([
462
+ ...BUILTIN_COMMAND_NAMES,
463
+ ...Object.keys(mutableCfg.command ?? {}),
464
+ ])
465
+ const commandAlloc = new NameAllocator(existingCommands)
466
+
467
+ // Seed the agent allocator with existing cfg keys ∪ built-in agent names.
468
+ const existingAgents = new Set<string>([
469
+ ...BUILTIN_AGENT_NAMES,
470
+ ...Object.keys(mutableCfg.agent ?? {}),
471
+ ])
472
+ const agentAlloc = new NameAllocator(existingAgents)
473
+
474
+ for (const plugin of plugins) {
475
+ await injectPluginCommands(plugin, mutableCfg, commandAlloc, summary, logger)
476
+ await injectPluginAgents(plugin, mutableCfg, agentAlloc, summary, logger)
477
+ }
478
+
479
+ return summary
480
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,54 @@
1
+ /** Thrown when a soft warning is promoted to a hard error under `strict` mode. */
2
+ export class BridgeError extends Error {
3
+ override name = "BridgeError"
4
+ }
5
+
6
+ /** Prefix every line the bridge emits so its output is greppable in OpenCode's logs. */
7
+ export const LOG_PREFIX = "[opencode-claude-bridge]"
8
+
9
+ export interface WarnOptions {
10
+ /**
11
+ * Whether this warning should be promoted to a hard error under `strict`.
12
+ * Defaults to `true` (parse failures, missing CLI). Set `false` for advisory
13
+ * warnings that must never abort the hook even in strict mode (e.g. the §9
14
+ * version-range notice, which always still attempts injection).
15
+ */
16
+ fatalInStrict?: boolean
17
+ }
18
+
19
+ export interface Logger {
20
+ /** Informational line (run summaries, resolved-plugin sets). Never throws. */
21
+ info(msg: string): void
22
+ /**
23
+ * A warning. Under `strict` it throws {@link BridgeError} unless
24
+ * `fatalInStrict: false` is passed.
25
+ */
26
+ warn(msg: string, opts?: WarnOptions): void
27
+ /** Returns `true` if at least one `warn()` call was made on this logger instance. */
28
+ hadWarnings(): boolean
29
+ }
30
+
31
+ /**
32
+ * Create a logger bound to the resolved `strict` flag. The hook itself is responsible
33
+ * for catching {@link BridgeError} in non-strict paths; in strict mode it lets the
34
+ * error propagate so OpenCode surfaces a hard failure (design §10).
35
+ */
36
+ export function createLogger(strict: boolean): Logger {
37
+ let warningCount = 0
38
+ return {
39
+ info(msg: string): void {
40
+ console.log(`${LOG_PREFIX} ${msg}`)
41
+ },
42
+ warn(msg: string, opts?: WarnOptions): void {
43
+ const fatalInStrict = opts?.fatalInStrict ?? true
44
+ warningCount++
45
+ if (strict && fatalInStrict) {
46
+ throw new BridgeError(msg)
47
+ }
48
+ console.warn(`${LOG_PREFIX} warning: ${msg}`)
49
+ },
50
+ hadWarnings(): boolean {
51
+ return warningCount > 0
52
+ },
53
+ }
54
+ }