@koriit/opencode-claude-bridge 0.1.11 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koriit/opencode-claude-bridge",
3
- "version": "0.1.11",
3
+ "version": "0.1.15",
4
4
  "description": "An OpenCode plugin that bridges enabled Claude Code plugins (commands, agents, skills, MCP, LSP) into OpenCode at runtime, namespaced so they never shadow your existing items.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -68,6 +68,7 @@ export const server: Plugin = async (_input, options) => {
68
68
 
69
69
  // §6.1 commands, §6.2 agents — inline injection into the shared cfg.
70
70
  const cmdAgentSummary = await injectCommandsAndAgents(selected, cfg, logger)
71
+ const { commandAllocator } = cmdAgentSummary
71
72
 
72
73
  // §6.3 skills — cfg.skills.paths injection (with bridge-cache copy on collision).
73
74
  const home = os.homedir()
@@ -85,6 +86,7 @@ export const server: Plugin = async (_input, options) => {
85
86
  home,
86
87
  projectDir: _input.directory,
87
88
  cacheRoot: process.env["OPENCODE_CLAUDE_BRIDGE_CACHE_ROOT"],
89
+ commandAllocator,
88
90
  }, logger)
89
91
 
90
92
  // §6.4 MCP — cfg.mcp injection (opt-in via allowMcp).
@@ -93,10 +95,32 @@ export const server: Plugin = async (_input, options) => {
93
95
  // §6.5 LSP — cfg.lsp injection (opt-in via allowLsp; respects cfg.lsp === false).
94
96
  const lspSummary = await injectLsp(selected, cfg, bridge.allowLsp, logger)
95
97
 
98
+ // Remove commands that OpenCode's native Claude integration may have
99
+ // auto-loaded from blocked plugins. Runs after all bridge injection so
100
+ // OpenCode's own loading has had time to run during the async awaits above.
101
+ const selectedIds = new Set(selected.map((p) => p.id))
102
+ const blockedPlugins = all.filter((p) => !selectedIds.has(p.id))
103
+ if (blockedPlugins.length > 0) {
104
+ const mutableCmd = (cfg as unknown as { command?: Record<string, { description?: string }> }).command
105
+ if (mutableCmd && typeof mutableCmd === "object") {
106
+ for (const [name, entry] of Object.entries(mutableCmd)) {
107
+ const desc = entry?.description
108
+ if (typeof desc === "string") {
109
+ for (const bp of blockedPlugins) {
110
+ if (desc.endsWith(`[${bp.id}]`)) {
111
+ delete mutableCmd[name]
112
+ break
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+
96
120
  // §10 concise per-run summary.
97
121
  const renamed = cmdAgentSummary.renamed + skillSummary.renamed + mcpSummary.renamed + lspSummary.renamed
98
122
  const summaryParts: string[] = [
99
- `injected ${cmdAgentSummary.commands} command(s), ${cmdAgentSummary.agents} agent(s), ${skillSummary.skills} skill(s), ${mcpSummary.servers} MCP server(s), ${lspSummary.servers} LSP server(s)`,
123
+ `injected ${cmdAgentSummary.commands + skillSummary.commandsAdded} command(s), ${cmdAgentSummary.agents} agent(s), ${skillSummary.skills} skill(s), ${mcpSummary.servers} MCP server(s), ${lspSummary.servers} LSP server(s)`,
100
124
  ]
101
125
  if (renamed > 0) summaryParts.push(`renamed ${renamed} (collision)`)
102
126
  if (mcpSummary.skippedPolicy > 0) summaryParts.push(`skipped ${mcpSummary.skippedPolicy} MCP (policy)`)
@@ -120,6 +144,11 @@ export const server: Plugin = async (_input, options) => {
120
144
  }
121
145
  },
122
146
 
147
+ "experimental.chat.system.transform": async (input, output) => {
148
+ if (input.sessionID)
149
+ output.system.push(`Session ID: ${input.sessionID}`)
150
+ },
151
+
123
152
  "chat.message": async () => {
124
153
  // Show a one-time toast on the first chat message if any warnings were emitted
125
154
  // during the config hook. The toast is best-effort — a failure must never throw.
package/src/inject.ts CHANGED
@@ -80,6 +80,11 @@ export interface InjectionSummary {
80
80
  renamed: number
81
81
  }
82
82
 
83
+ /** Full result of {@link injectCommandsAndAgents}, including the populated command allocator. */
84
+ export interface InjectionResult extends InjectionSummary {
85
+ commandAllocator: NameAllocator
86
+ }
87
+
83
88
  // ── File collection helpers ────────────────────────────────────────────────────
84
89
 
85
90
  /**
@@ -405,6 +410,41 @@ export function sanitizeAgentColor(value: string): string | null {
405
410
  return null
406
411
  }
407
412
 
413
+ // ── Single-entry command injection ───────────────────────────────────────────
414
+
415
+ /**
416
+ * Insert one command entry into `cfg.command` using the allocator, appending `[pluginId]`
417
+ * to the description. Returns the allocated name and whether it was renamed.
418
+ *
419
+ * The `entry.template` must already be fully resolved by the caller (e.g. via
420
+ * `resolvePluginRoot` for commands from the plugin's commands/ dir, or verbatim for
421
+ * skill-derived commands whose body does not use `${CLAUDE_PLUGIN_ROOT}`).
422
+ */
423
+ export function injectCommandEntry(
424
+ bareName: string,
425
+ entry: CommandEntry,
426
+ cfg: InjectableConfig,
427
+ allocator: NameAllocator,
428
+ pluginId: string,
429
+ logger: Logger,
430
+ ): { allocatedName: string; renamed: boolean } {
431
+ if (cfg.command === undefined || cfg.command === null || typeof cfg.command !== "object") {
432
+ cfg.command = {}
433
+ }
434
+
435
+ const { name: allocatedName, renamed } = allocator.claim(pluginId, bareName)
436
+
437
+ const rawDescription = entry.description
438
+ const tracedDescription = rawDescription
439
+ ? `${rawDescription} [${pluginId}]`
440
+ : `${bareName} [${pluginId}]`
441
+
442
+ warnUnsupportedPlaceholders(entry.template, allocatedName, pluginId, logger)
443
+
444
+ cfg.command[allocatedName] = { ...entry, description: tracedDescription }
445
+ return { allocatedName, renamed }
446
+ }
447
+
408
448
  // ── Command injection for one plugin ─────────────────────────────────────────
409
449
 
410
450
  /**
@@ -666,11 +706,8 @@ export async function injectCommandsAndAgents(
666
706
  plugins: ClaudePlugin[],
667
707
  cfg: Config,
668
708
  logger: Logger,
669
- ): Promise<InjectionSummary> {
709
+ ): Promise<InjectionResult> {
670
710
  const mutableCfg = cfg as unknown as InjectableConfig
671
- const summary: InjectionSummary = { commands: 0, agents: 0, renamed: 0 }
672
-
673
- if (plugins.length === 0) return summary
674
711
 
675
712
  // Seed the command allocator with existing cfg keys ∪ built-in command names.
676
713
  const existingCommands = new Set<string>([
@@ -679,6 +716,10 @@ export async function injectCommandsAndAgents(
679
716
  ])
680
717
  const commandAlloc = new NameAllocator(existingCommands)
681
718
 
719
+ const summary: InjectionSummary = { commands: 0, agents: 0, renamed: 0 }
720
+
721
+ if (plugins.length === 0) return { ...summary, commandAllocator: commandAlloc }
722
+
682
723
  // Seed the agent allocator with existing cfg keys ∪ built-in agent names.
683
724
  const existingAgents = new Set<string>([
684
725
  ...BUILTIN_AGENT_NAMES,
@@ -691,5 +732,5 @@ export async function injectCommandsAndAgents(
691
732
  await injectPluginAgents(plugin, mutableCfg, agentAlloc, summary, logger)
692
733
  }
693
734
 
694
- return summary
735
+ return { ...summary, commandAllocator: commandAlloc }
695
736
  }
package/src/logger.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from "node:fs"
2
+
1
3
  /** Thrown when a soft warning is promoted to a hard error under `strict` mode. */
2
4
  export class BridgeError extends Error {
3
5
  override name = "BridgeError"
@@ -25,48 +27,42 @@ export interface Logger {
25
27
  }
26
28
 
27
29
  /**
28
- * Minimal duck-type for the OpenCode core logger service we resolve at runtime.
29
- * `@opencode-ai/core` is private/unpublished; we access it via dynamic import
30
- * against the Bun module registry that the host worker already populated.
30
+ * Detect whether --print-logs was passed to the parent OpenCode process.
31
+ *
32
+ * process.argv is stripped in Bun Workers (only ["bun", "<worker_script>"] is
33
+ * present), so we cannot check it directly. However, Workers run in the same
34
+ * OS process as the host, so /proc/self/cmdline contains the real command line.
35
+ * Falls back to false on non-Linux platforms or if the file is unreadable.
31
36
  */
32
- interface CoreLog {
33
- create(tags?: Record<string, unknown>): {
34
- info(msg: string): void
35
- warn(msg: string): void
37
+ function detectPrintLogs(): boolean {
38
+ try {
39
+ const args = readFileSync("/proc/self/cmdline").toString().split("\0")
40
+ return args.includes("--print-logs")
41
+ } catch {
42
+ return false
36
43
  }
37
44
  }
38
45
 
46
+ const printLogs = detectPrintLogs()
47
+
39
48
  /**
40
- * Resolve the core log module at runtime by hitting Bun's module registry.
41
- * The Bun Worker that hosts plugins already executed
42
- * `import * as Log from "@opencode-ai/core/util/log"`
43
- * and called `Log.init({ print: ... })`, so the registry holds a fully
44
- * configured instance. A dynamic import of the same specifier returns it.
45
- *
46
- * Falls back to a plain stderr writer if the import fails (tests, non-OpenCode
47
- * environments) — in that case output is always emitted so tests can capture it.
49
+ * Try to import @opencode-ai/core/util/log from the host Bun Worker's module
50
+ * registry. If available, its already-initialized logger routes output to
51
+ * stderr (with --print-logs) or the log file (default) without any extra
52
+ * argv inspection. Falls back to null when the module is unavailable.
48
53
  */
49
- async function resolveCoreLog(): Promise<CoreLog | null> {
54
+ async function resolveCoreLog() {
50
55
  try {
51
- // @opencode-ai/core is a private package bundled into the OpenCode binary.
52
- // The dynamic import resolves against Bun's module registry at runtime —
53
- // the host worker already loaded and initialized it.
54
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
55
- // @ts-ignore — not in node_modules; resolved from the Bun bundle at runtime
56
- return await import("@opencode-ai/core/util/log") as CoreLog
56
+ return await import("@opencode-ai/core/util/log")
57
57
  } catch {
58
58
  return null
59
59
  }
60
60
  }
61
61
 
62
- // Kick off resolution immediately so it's ready before the first log call.
63
62
  const coreLogPromise = resolveCoreLog()
64
63
 
65
- /**
66
- * Fallback writer used when the core log module is unavailable.
67
- * Always writes to stderr — correct for test environments.
68
- */
69
64
  function fallbackWrite(level: "INFO" | "WARN", msg: string): void {
65
+ if (!printLogs) return
70
66
  const ts = new Date().toISOString().split(".")[0]
71
67
  process.stderr.write(`${level.padEnd(5)} ${ts} service=opencode-claude-bridge ${msg}\n`)
72
68
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Type declarations for @opencode-ai/core — a private package bundled into the
3
+ * OpenCode binary and not published to npm. Only the subset the bridge actually
4
+ * uses is declared here; the full API is larger.
5
+ *
6
+ * Resolved at runtime via dynamic import against Bun's module registry, which
7
+ * already holds the host worker's pre-initialized instance.
8
+ */
9
+ declare module "@opencode-ai/core/util/log" {
10
+ export interface Logger {
11
+ debug(message?: unknown, extra?: Record<string, unknown>): void
12
+ info(message?: unknown, extra?: Record<string, unknown>): void
13
+ warn(message?: unknown, extra?: Record<string, unknown>): void
14
+ error(message?: unknown, extra?: Record<string, unknown>): void
15
+ }
16
+
17
+ export function create(tags?: Record<string, unknown>): Logger
18
+ }
@@ -29,6 +29,8 @@ import path from "node:path"
29
29
  import type { Config } from "@opencode-ai/plugin"
30
30
  import { extractSkillName } from "./skill-scan.js"
31
31
  import { NameAllocator, splitPluginId } from "./naming.js"
32
+ import { parseFrontmatter, FRONTMATTER_PARSE_ERROR } from "./frontmatter.js"
33
+ import { injectCommandEntry } from "./inject.js"
32
34
  import type { Logger } from "./logger.js"
33
35
  import type { ClaudePlugin } from "./types.js"
34
36
 
@@ -55,6 +57,7 @@ interface NormalizedSkillsConfig {
55
57
 
56
58
  interface InjectableConfig {
57
59
  skills?: SkillsConfig | boolean | undefined
60
+ command?: Record<string, unknown> | undefined
58
61
  }
59
62
 
60
63
  /**
@@ -317,14 +320,20 @@ export function patchSkillName(content: string, newName: string): string {
317
320
 
318
321
  /**
319
322
  * Scan `<installPath>/skills/` for skill subdirectories and inject each one into
320
- * `cfg.skills.paths`, performing a cache copy + frontmatter patch on collision.
323
+ * `cfg.skills.paths` and/or `cfg.command` based on the skill's frontmatter flags.
321
324
  *
322
- * @returns the number of skills injected and collisions renamed.
325
+ * Routing table (from `user-invocable` and `disable-model-invocation` frontmatter):
326
+ * - neither flag → inject as skill AND command
327
+ * - user-invocable: false → inject as skill only (no command)
328
+ * - disable-model-invocation: true → inject as command only (no skill)
329
+ * - both flags set → skip entirely with a WARN
323
330
  */
324
331
  async function injectPluginSkills(
325
332
  plugin: ClaudePlugin,
326
333
  skillsCfg: NormalizedSkillsConfig,
327
- allocator: NameAllocator,
334
+ mutableCfg: InjectableConfig,
335
+ skillAllocator: NameAllocator,
336
+ commandAllocator: NameAllocator,
328
337
  cacheRoot: string,
329
338
  summary: SkillInjectionSummary,
330
339
  logger: Logger,
@@ -363,36 +372,81 @@ async function injectPluginSkills(
363
372
  )
364
373
  }
365
374
 
366
- const { name: allocatedName, renamed } = allocator.claim(plugin.id, bareName)
367
-
368
- if (!renamed) {
369
- // No collision — point OpenCode directly at the plugin's skill dir.
370
- skillsCfg.paths.push(skillDir)
371
- summary.skills++
372
- } else {
373
- // Collision copy the skill dir into the bridge cache and patch the name.
374
- const cachedSkillDir = cacheDirForSkill(cacheRoot, plugin, allocatedName)
375
- const cachedSkillMd = path.join(cachedSkillDir, "SKILL.md")
376
-
377
- const stale = await isCacheStale(skillMdPath, cachedSkillMd)
378
- if (stale) {
379
- try {
380
- await copyDirRecursive(skillDir, cachedSkillDir, logger)
381
- // Patch using the content already in memory (read above for extractSkillName)
382
- // rather than re-reading the just-copied file — same bytes, avoids a round-trip.
383
- const patched = patchSkillName(content, allocatedName)
384
- await fs.writeFile(cachedSkillMd, patched, "utf8")
385
- } catch (err) {
386
- logger.warn(
387
- `failed to create bridge-cache copy for skill "${bareName}" from plugin "${plugin.id}" (${err instanceof Error ? err.message : String(err)}); skipping`,
388
- )
389
- continue
375
+ // Parse additional frontmatter flags that control injection routing.
376
+ const parsed = parseFrontmatter(content)
377
+ let userInvocable = true
378
+ let disableModelInvocation = false
379
+ let description: string | undefined
380
+ let body = ""
381
+
382
+ if (parsed !== null && parsed !== FRONTMATTER_PARSE_ERROR) {
383
+ const fm = parsed.data
384
+ if (fm["user-invocable"] === false) userInvocable = false
385
+ if (fm["disable-model-invocation"] === true) disableModelInvocation = true
386
+ if (typeof fm["description"] === "string") description = fm["description"]
387
+ body = parsed.body
388
+ }
389
+
390
+ const asSkill = !disableModelInvocation
391
+ const asCommand = userInvocable
392
+
393
+ if (!asSkill && !asCommand) {
394
+ logger.warn(
395
+ `skill "${bareName}" from plugin "${plugin.id}" has both "disable-model-invocation: true" and "user-invocable: false"; skipping`,
396
+ { fatalInStrict: false },
397
+ )
398
+ continue
399
+ }
400
+
401
+ if (asSkill) {
402
+ const { name: allocatedName, renamed } = skillAllocator.claim(plugin.id, bareName)
403
+
404
+ if (!renamed) {
405
+ // No collision — point OpenCode directly at the plugin's skill dir.
406
+ skillsCfg.paths.push(skillDir)
407
+ summary.skills++
408
+ } else {
409
+ // Collision — copy the skill dir into the bridge cache and patch the name.
410
+ const cachedSkillDir = cacheDirForSkill(cacheRoot, plugin, allocatedName)
411
+ const cachedSkillMd = path.join(cachedSkillDir, "SKILL.md")
412
+
413
+ const stale = await isCacheStale(skillMdPath, cachedSkillMd)
414
+ if (stale) {
415
+ try {
416
+ await copyDirRecursive(skillDir, cachedSkillDir, logger)
417
+ // Patch using the content already in memory (read above for extractSkillName)
418
+ // rather than re-reading the just-copied file — same bytes, avoids a round-trip.
419
+ const patched = patchSkillName(content, allocatedName)
420
+ await fs.writeFile(cachedSkillMd, patched, "utf8")
421
+ } catch (err) {
422
+ logger.warn(
423
+ `failed to create bridge-cache copy for skill "${bareName}" from plugin "${plugin.id}" (${err instanceof Error ? err.message : String(err)}); skipping`,
424
+ )
425
+ continue
426
+ }
390
427
  }
428
+
429
+ skillsCfg.paths.push(cachedSkillDir)
430
+ summary.skills++
431
+ summary.renamed++
391
432
  }
433
+ }
392
434
 
393
- skillsCfg.paths.push(cachedSkillDir)
394
- summary.skills++
395
- summary.renamed++
435
+ if (asCommand) {
436
+ if (!body) continue
437
+ const fmData = (parsed !== null && parsed !== FRONTMATTER_PARSE_ERROR) ? parsed.data : {}
438
+ const modelRaw = fmData["model"]
439
+ const model = typeof modelRaw === "string" && modelRaw.includes("/") ? modelRaw : undefined
440
+ const { renamed } = injectCommandEntry(
441
+ bareName,
442
+ { template: body, description, model },
443
+ mutableCfg as unknown as { command?: Record<string, import("./inject.js").CommandEntry> },
444
+ commandAllocator,
445
+ plugin.id,
446
+ logger,
447
+ )
448
+ summary.commandsAdded++
449
+ if (renamed) summary.renamed++
396
450
  }
397
451
  }
398
452
  }
@@ -403,6 +457,7 @@ async function injectPluginSkills(
403
457
  export interface SkillInjectionSummary {
404
458
  skills: number
405
459
  renamed: number
460
+ commandsAdded: number
406
461
  }
407
462
 
408
463
  /**
@@ -424,6 +479,13 @@ export interface SkillInjectOptions {
424
479
  * to `~/.cache/opencode-claude-bridge/skills/`.
425
480
  */
426
481
  cacheRoot?: string
482
+ /**
483
+ * The fully-populated command name allocator from `injectCommandsAndAgents`.
484
+ * Required to participate in the same command namespace so skill-derived
485
+ * commands never collide with plugin commands or built-in commands.
486
+ * When omitted (e.g. in legacy callers), a fresh allocator is created.
487
+ */
488
+ commandAllocator?: NameAllocator
427
489
  }
428
490
 
429
491
  /**
@@ -451,7 +513,7 @@ export async function injectSkills(
451
513
  logger: Logger,
452
514
  ): Promise<SkillInjectionSummary> {
453
515
  const mutableCfg = cfg as unknown as InjectableConfig
454
- const summary: SkillInjectionSummary = { skills: 0, renamed: 0 }
516
+ const summary: SkillInjectionSummary = { skills: 0, renamed: 0, commandsAdded: 0 }
455
517
 
456
518
  if (plugins.length === 0) return summary
457
519
 
@@ -469,13 +531,17 @@ export async function injectSkills(
469
531
  const cacheRoot =
470
532
  opts.cacheRoot ?? path.join(opts.home, ".cache", "opencode-claude-bridge", "skills")
471
533
 
472
- // Seed the allocator with every name OpenCode already knows about (native + built-ins).
534
+ // Seed the skill allocator with every name OpenCode already knows about (native + built-ins).
473
535
  // `existingSkillNames` is provided by the caller (from `collectExistingSkillNames`) to
474
536
  // avoid calling the Skill service from the hook.
475
- const allocator = new NameAllocator(existingSkillNames)
537
+ const skillAllocator = new NameAllocator(existingSkillNames)
538
+
539
+ // Use the caller-provided command allocator so skill-derived commands share the
540
+ // same namespace as plugin commands. Fall back to a fresh one if not provided.
541
+ const commandAllocator = opts.commandAllocator ?? new NameAllocator(new Set<string>())
476
542
 
477
543
  for (const plugin of plugins) {
478
- await injectPluginSkills(plugin, skillsCfg, allocator, cacheRoot, summary, logger)
544
+ await injectPluginSkills(plugin, skillsCfg, mutableCfg, skillAllocator, commandAllocator, cacheRoot, summary, logger)
479
545
  }
480
546
 
481
547
  return summary