@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/LICENSE +21 -0
- package/README.md +264 -0
- package/package.json +54 -0
- package/src/config.ts +82 -0
- package/src/frontmatter.ts +159 -0
- package/src/index.ts +158 -0
- package/src/inject.ts +480 -0
- package/src/logger.ts +54 -0
- package/src/lsp-inject.ts +405 -0
- package/src/mcp-inject.ts +381 -0
- package/src/naming.ts +122 -0
- package/src/opencode-builtins.ts +98 -0
- package/src/selection.ts +122 -0
- package/src/skill-inject.ts +480 -0
- package/src/skill-scan.ts +349 -0
- package/src/types.ts +46 -0
- package/src/version.ts +114 -0
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
|
+
}
|