@koriit/opencode-claude-bridge 0.1.11 → 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 +1 -1
- package/src/index.ts +3 -1
- package/src/inject.ts +46 -5
- package/src/logger.ts +10 -30
- package/src/opencode-core.d.ts +18 -0
- package/src/skill-inject.ts +100 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koriit/opencode-claude-bridge",
|
|
3
|
-
"version": "0.1.
|
|
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
|
@@ -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).
|
|
@@ -96,7 +98,7 @@ export const server: Plugin = async (_input, options) => {
|
|
|
96
98
|
// §10 concise per-run summary.
|
|
97
99
|
const renamed = cmdAgentSummary.renamed + skillSummary.renamed + mcpSummary.renamed + lspSummary.renamed
|
|
98
100
|
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)`,
|
|
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)`,
|
|
100
102
|
]
|
|
101
103
|
if (renamed > 0) summaryParts.push(`renamed ${renamed} (collision)`)
|
|
102
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<
|
|
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
|
@@ -24,48 +24,28 @@ export interface Logger {
|
|
|
24
24
|
hadWarnings(): boolean
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
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.
|
|
31
|
-
*/
|
|
32
|
-
interface CoreLog {
|
|
33
|
-
create(tags?: Record<string, unknown>): {
|
|
34
|
-
info(msg: string): void
|
|
35
|
-
warn(msg: string): void
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
27
|
/**
|
|
40
28
|
* 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
29
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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).
|
|
48
38
|
*/
|
|
49
|
-
async function resolveCoreLog()
|
|
39
|
+
async function resolveCoreLog() {
|
|
50
40
|
try {
|
|
51
|
-
|
|
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
|
|
41
|
+
return await import("@opencode-ai/core/util/log")
|
|
57
42
|
} catch {
|
|
58
43
|
return null
|
|
59
44
|
}
|
|
60
45
|
}
|
|
61
46
|
|
|
62
|
-
// Kick off resolution immediately so it's ready before the first log call.
|
|
63
47
|
const coreLogPromise = resolveCoreLog()
|
|
64
48
|
|
|
65
|
-
/**
|
|
66
|
-
* Fallback writer used when the core log module is unavailable.
|
|
67
|
-
* Always writes to stderr — correct for test environments.
|
|
68
|
-
*/
|
|
69
49
|
function fallbackWrite(level: "INFO" | "WARN", msg: string): void {
|
|
70
50
|
const ts = new Date().toISOString().split(".")[0]
|
|
71
51
|
process.stderr.write(`${level.padEnd(5)} ${ts} service=opencode-claude-bridge ${msg}\n`)
|
|
@@ -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
|
+
}
|
package/src/skill-inject.ts
CHANGED
|
@@ -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
|
|
323
|
+
* `cfg.skills.paths` and/or `cfg.command` based on the skill's frontmatter flags.
|
|
321
324
|
*
|
|
322
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
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,
|
|
544
|
+
await injectPluginSkills(plugin, skillsCfg, mutableCfg, skillAllocator, commandAllocator, cacheRoot, summary, logger)
|
|
479
545
|
}
|
|
480
546
|
|
|
481
547
|
return summary
|