@koriit/opencode-claude-bridge 0.1.10 → 0.1.13

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.10",
3
+ "version": "0.1.13",
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
@@ -51,9 +51,6 @@ export const server: Plugin = async (_input, options) => {
51
51
 
52
52
  return {
53
53
  config: async (cfg) => {
54
- // TEMPORARY DIAGNOSTIC — remove before next release
55
- process.stderr.write(`[ocb-debug] process.argv=${JSON.stringify(process.argv)}\n`)
56
- process.stderr.write(`[ocb-debug] has --print-logs: ${process.argv.includes("--print-logs")}\n`)
57
54
  const logger = createLogger(bridge.strict)
58
55
  try {
59
56
  // Replay parse-time validation warnings (strict-promotable).
@@ -71,6 +68,7 @@ export const server: Plugin = async (_input, options) => {
71
68
 
72
69
  // §6.1 commands, §6.2 agents — inline injection into the shared cfg.
73
70
  const cmdAgentSummary = await injectCommandsAndAgents(selected, cfg, logger)
71
+ const { commandAllocator } = cmdAgentSummary
74
72
 
75
73
  // §6.3 skills — cfg.skills.paths injection (with bridge-cache copy on collision).
76
74
  const home = os.homedir()
@@ -88,6 +86,7 @@ export const server: Plugin = async (_input, options) => {
88
86
  home,
89
87
  projectDir: _input.directory,
90
88
  cacheRoot: process.env["OPENCODE_CLAUDE_BRIDGE_CACHE_ROOT"],
89
+ commandAllocator,
91
90
  }, logger)
92
91
 
93
92
  // §6.4 MCP — cfg.mcp injection (opt-in via allowMcp).
@@ -99,7 +98,7 @@ export const server: Plugin = async (_input, options) => {
99
98
  // §10 concise per-run summary.
100
99
  const renamed = cmdAgentSummary.renamed + skillSummary.renamed + mcpSummary.renamed + lspSummary.renamed
101
100
  const summaryParts: string[] = [
102
- `injected ${cmdAgentSummary.commands} command(s), ${cmdAgentSummary.agents} agent(s), ${skillSummary.skills} skill(s), ${mcpSummary.servers} MCP server(s), ${lspSummary.servers} LSP server(s)`,
101
+ `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)`,
103
102
  ]
104
103
  if (renamed > 0) summaryParts.push(`renamed ${renamed} (collision)`)
105
104
  if (mcpSummary.skippedPolicy > 0) summaryParts.push(`skipped ${mcpSummary.skippedPolicy} MCP (policy)`)
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
@@ -25,29 +25,42 @@ export interface Logger {
25
25
  }
26
26
 
27
27
  /**
28
- * Internal output logger. Writes to process.stderr in OpenCode's structured
29
- * log format: `LEVEL ISO-timestamp +Xms service=opencode-claude-bridge <message>`
28
+ * Resolve the core log module at runtime by hitting Bun's module registry.
30
29
  *
31
- * Gated on --print-logs to match OpenCode's own log-visibility behaviour.
30
+ * @opencode-ai/core is private and not on npm, but the host Bun Worker already
31
+ * executed `import * as Log from "@opencode-ai/core/util/log"` and called
32
+ * `Log.init({ print: ... })`. A dynamic import of the same specifier returns
33
+ * the cached, fully-configured instance — so output correctly goes to stderr
34
+ * (with --print-logs) or the log file (default), with no argv check needed.
35
+ *
36
+ * Falls back to direct stderr writes when the import fails (tests, or if the
37
+ * module path changes in a future OpenCode version).
32
38
  */
33
- const log = (() => {
34
- const enabled = process.argv.includes("--print-logs")
35
- let last = Date.now()
36
-
37
- function write(level: "INFO" | "WARN", msg: string): void {
38
- if (!enabled) return
39
- const now = Date.now()
40
- const ts = new Date(now).toISOString().split(".")[0]
41
- const diff = now - last
42
- last = now
43
- process.stderr.write(`${level.padEnd(5)} ${ts} +${diff}ms service=opencode-claude-bridge ${msg}\n`)
39
+ async function resolveCoreLog() {
40
+ try {
41
+ return await import("@opencode-ai/core/util/log")
42
+ } catch {
43
+ return null
44
44
  }
45
+ }
45
46
 
46
- return {
47
- info: (msg: string) => write("INFO", msg),
48
- warn: (msg: string) => write("WARN", msg),
47
+ const coreLogPromise = resolveCoreLog()
48
+
49
+ function fallbackWrite(level: "INFO" | "WARN", msg: string): void {
50
+ const ts = new Date().toISOString().split(".")[0]
51
+ process.stderr.write(`${level.padEnd(5)} ${ts} service=opencode-claude-bridge ${msg}\n`)
52
+ }
53
+
54
+ async function emit(level: "INFO" | "WARN", msg: string): Promise<void> {
55
+ const core = await coreLogPromise
56
+ if (core) {
57
+ const svc = core.create({ service: "opencode-claude-bridge" })
58
+ if (level === "INFO") svc.info(msg)
59
+ else svc.warn(msg)
60
+ } else {
61
+ fallbackWrite(level, msg)
49
62
  }
50
- })()
63
+ }
51
64
 
52
65
  /**
53
66
  * Create a logger bound to the resolved `strict` flag. The hook itself is
@@ -58,13 +71,13 @@ export function createLogger(strict: boolean): Logger {
58
71
  let warningCount = 0
59
72
  return {
60
73
  info(msg) {
61
- log.info(msg)
74
+ void emit("INFO", msg)
62
75
  },
63
76
  warn(msg, opts) {
64
77
  const fatalInStrict = opts?.fatalInStrict ?? true
65
78
  warningCount++
66
79
  if (strict && fatalInStrict) throw new BridgeError(msg)
67
- log.warn(msg)
80
+ void emit("WARN", msg)
68
81
  },
69
82
  hadWarnings() {
70
83
  return warningCount > 0
@@ -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