@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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP injection from enabled Claude plugins into OpenCode's flat `cfg.mcp` record.
|
|
3
|
+
*
|
|
4
|
+
* Only runs when the global `allowMcp` toggle is on (safe-by-default off per §8).
|
|
5
|
+
*
|
|
6
|
+
* Source: each plugin's top-level `<installPath>/.mcp.json` — the canonical Claude
|
|
7
|
+
* MCP location (verified against real installed plugins: slack, atlassian, zoom).
|
|
8
|
+
* `plugin.json` holds metadata only and never contains `mcpServers`.
|
|
9
|
+
*
|
|
10
|
+
* Mapping (Claude → OpenCode V1 flat shape):
|
|
11
|
+
* - `type:"http"` (remote) → `{ type:"remote", url, headers?, oauth? }`
|
|
12
|
+
* - stdio/command (no type, or `type:"stdio"`) → `{ type:"local", command:[cmd, ...args], environment? }`
|
|
13
|
+
* - `${CLAUDE_PLUGIN_ROOT}` resolved to `installPath` in all string fields
|
|
14
|
+
*
|
|
15
|
+
* oauth: OpenCode V1 uses the same camelCase field names as Claude's `.mcp.json`
|
|
16
|
+
* (`clientId`, `clientSecret`, `scope`, `callbackPort`, `redirectUri`). No rename
|
|
17
|
+
* is needed — the fields are passed through directly. (Appendix B open item resolved.)
|
|
18
|
+
*
|
|
19
|
+
* Naming: base name `<plugin>-<server>`, then apply the §7 NameAllocator collision
|
|
20
|
+
* rules vs existing `cfg.mcp` keys.
|
|
21
|
+
*
|
|
22
|
+
* Skip-and-warn (§10): unreadable/unparseable `.mcp.json` or a malformed server
|
|
23
|
+
* entry logs a warning and that item is skipped; the hook does not throw in non-strict
|
|
24
|
+
* mode.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs/promises"
|
|
28
|
+
import path from "node:path"
|
|
29
|
+
import type { Config } from "@opencode-ai/plugin"
|
|
30
|
+
import { NameAllocator, splitPluginId } from "./naming.js"
|
|
31
|
+
import type { Logger } from "./logger.js"
|
|
32
|
+
import type { ClaudePlugin } from "./types.js"
|
|
33
|
+
|
|
34
|
+
// ── OpenCode V1 MCP entry shapes ──────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* OpenCode V1 oauth configuration shape (same field names as Claude's `.mcp.json`).
|
|
38
|
+
* Verified against OpenCode v1.15.x packages/core/src/v1/config/mcp.ts:
|
|
39
|
+
* `clientId`, `clientSecret`, `scope`, `callbackPort`, `redirectUri`.
|
|
40
|
+
*/
|
|
41
|
+
export interface McpOauth {
|
|
42
|
+
clientId?: string
|
|
43
|
+
clientSecret?: string
|
|
44
|
+
scope?: string
|
|
45
|
+
callbackPort?: number
|
|
46
|
+
redirectUri?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** OpenCode V1 local MCP entry (`type:"local"`). */
|
|
50
|
+
export interface McpLocalEntry {
|
|
51
|
+
type: "local"
|
|
52
|
+
command: string[]
|
|
53
|
+
environment?: Record<string, string>
|
|
54
|
+
enabled?: boolean
|
|
55
|
+
timeout?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** OpenCode V1 remote MCP entry (`type:"remote"`). */
|
|
59
|
+
export interface McpRemoteEntry {
|
|
60
|
+
type: "remote"
|
|
61
|
+
url: string
|
|
62
|
+
headers?: Record<string, string>
|
|
63
|
+
oauth?: McpOauth | false
|
|
64
|
+
enabled?: boolean
|
|
65
|
+
timeout?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type McpEntry = McpLocalEntry | McpRemoteEntry
|
|
69
|
+
|
|
70
|
+
// ── Config shape guard ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
interface InjectableMcpConfig {
|
|
73
|
+
mcp?: Record<string, McpEntry> | undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function guardMcpConfig(cfg: InjectableMcpConfig): Record<string, McpEntry> {
|
|
77
|
+
if (cfg.mcp === undefined || cfg.mcp === null || typeof cfg.mcp !== "object") {
|
|
78
|
+
cfg.mcp = {}
|
|
79
|
+
}
|
|
80
|
+
return cfg.mcp
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Claude .mcp.json schema ───────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Claude's MCP server entry as it appears in `.mcp.json`.
|
|
87
|
+
* Verified against real installed plugins (slack, atlassian, zoom-skills).
|
|
88
|
+
*/
|
|
89
|
+
interface ClaudeMcpOauthObject {
|
|
90
|
+
clientId?: string
|
|
91
|
+
clientSecret?: string
|
|
92
|
+
scope?: string
|
|
93
|
+
callbackPort?: number
|
|
94
|
+
redirectUri?: string
|
|
95
|
+
[key: string]: unknown
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ClaudeMcpServer {
|
|
99
|
+
type?: "http" | "stdio" | string
|
|
100
|
+
url?: string
|
|
101
|
+
command?: string
|
|
102
|
+
args?: string[]
|
|
103
|
+
env?: Record<string, string>
|
|
104
|
+
headers?: Record<string, string>
|
|
105
|
+
/** Claude oauth may be an object or `false` (to disable OAuth auto-detection). */
|
|
106
|
+
oauth?: ClaudeMcpOauthObject | false
|
|
107
|
+
[key: string]: unknown
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ClaudeMcpJson {
|
|
111
|
+
mcpServers?: Record<string, ClaudeMcpServer>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Resolution helpers ────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve `${CLAUDE_PLUGIN_ROOT}` in a string to the absolute `installPath`.
|
|
118
|
+
*/
|
|
119
|
+
function resolvePluginRoot(text: string, installPath: string): string {
|
|
120
|
+
return text.replaceAll("${CLAUDE_PLUGIN_ROOT}", installPath)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve `${CLAUDE_PLUGIN_ROOT}` in all string values of a record.
|
|
125
|
+
*
|
|
126
|
+
* Non-string values (e.g. a numeric header or env value from a loose JSON file)
|
|
127
|
+
* are silently dropped rather than reaching `String.prototype.replaceAll` and
|
|
128
|
+
* throwing a TypeError that would kill the whole hook run (§10).
|
|
129
|
+
*/
|
|
130
|
+
function resolveRecordValues(
|
|
131
|
+
record: Record<string, unknown>,
|
|
132
|
+
installPath: string,
|
|
133
|
+
): Record<string, string> {
|
|
134
|
+
const out: Record<string, string> = {}
|
|
135
|
+
for (const [k, v] of Object.entries(record)) {
|
|
136
|
+
if (typeof v === "string") out[k] = resolvePluginRoot(v, installPath)
|
|
137
|
+
// Non-string values are dropped — they cannot be path-resolved and are
|
|
138
|
+
// not valid in the target OpenCode string-record fields (headers, env).
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Claude → OpenCode mapping ─────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Map a Claude `.mcp.json` server entry to an OpenCode V1 MCP entry.
|
|
147
|
+
*
|
|
148
|
+
* - `type:"http"` → `{ type:"remote", url, headers?, oauth? }`
|
|
149
|
+
* - everything else (stdio, absent) → `{ type:"local", command:[cmd,...args], environment? }`
|
|
150
|
+
* - `${CLAUDE_PLUGIN_ROOT}` resolved in all string fields
|
|
151
|
+
* - oauth fields passed through with same camelCase names (OpenCode V1 matches Claude exactly)
|
|
152
|
+
*
|
|
153
|
+
* Returns `null` if the entry is malformed and should be skipped.
|
|
154
|
+
*/
|
|
155
|
+
export function mapClaudeMcpServer(
|
|
156
|
+
server: ClaudeMcpServer,
|
|
157
|
+
installPath: string,
|
|
158
|
+
): McpEntry | null {
|
|
159
|
+
if (server.type === "http") {
|
|
160
|
+
// Remote entry
|
|
161
|
+
if (!server.url || typeof server.url !== "string") return null
|
|
162
|
+
const url = resolvePluginRoot(server.url, installPath)
|
|
163
|
+
|
|
164
|
+
const entry: McpRemoteEntry = { type: "remote", url }
|
|
165
|
+
|
|
166
|
+
if (server.headers && typeof server.headers === "object") {
|
|
167
|
+
entry.headers = resolveRecordValues(server.headers, installPath)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (server.oauth !== undefined) {
|
|
171
|
+
if (server.oauth === false) {
|
|
172
|
+
entry.oauth = false
|
|
173
|
+
} else if (typeof server.oauth === "object") {
|
|
174
|
+
// Pass through camelCase oauth fields verbatim — OpenCode V1 uses the
|
|
175
|
+
// same field names as Claude: clientId, clientSecret, scope, callbackPort,
|
|
176
|
+
// redirectUri. Verified against OpenCode src/v1/config/mcp.ts.
|
|
177
|
+
const oauth: McpOauth = {}
|
|
178
|
+
if (typeof server.oauth.clientId === "string") oauth.clientId = server.oauth.clientId
|
|
179
|
+
if (typeof server.oauth.clientSecret === "string") oauth.clientSecret = server.oauth.clientSecret
|
|
180
|
+
if (typeof server.oauth.scope === "string") oauth.scope = server.oauth.scope
|
|
181
|
+
if (typeof server.oauth.callbackPort === "number") oauth.callbackPort = server.oauth.callbackPort
|
|
182
|
+
if (typeof server.oauth.redirectUri === "string") oauth.redirectUri = server.oauth.redirectUri
|
|
183
|
+
entry.oauth = oauth
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return entry
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Local / stdio entry
|
|
191
|
+
if (!server.command || typeof server.command !== "string") return null
|
|
192
|
+
const cmd = resolvePluginRoot(server.command, installPath)
|
|
193
|
+
const args = Array.isArray(server.args)
|
|
194
|
+
? server.args.map((a) => (typeof a === "string" ? resolvePluginRoot(a, installPath) : String(a)))
|
|
195
|
+
: []
|
|
196
|
+
|
|
197
|
+
const entry: McpLocalEntry = { type: "local", command: [cmd, ...args] }
|
|
198
|
+
|
|
199
|
+
if (server.env && typeof server.env === "object") {
|
|
200
|
+
entry.environment = resolveRecordValues(
|
|
201
|
+
server.env as Record<string, unknown>,
|
|
202
|
+
installPath,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return entry
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Per-plugin injection ──────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read and parse `<installPath>/.mcp.json`. Returns the parsed object or `null`
|
|
213
|
+
* if the file is absent or unreadable (caller logs and skips).
|
|
214
|
+
*/
|
|
215
|
+
async function readMcpJson(
|
|
216
|
+
installPath: string,
|
|
217
|
+
pluginId: string,
|
|
218
|
+
logger: Logger,
|
|
219
|
+
): Promise<ClaudeMcpJson | null> {
|
|
220
|
+
const mcpPath = path.join(installPath, ".mcp.json")
|
|
221
|
+
let raw: string
|
|
222
|
+
try {
|
|
223
|
+
raw = await fs.readFile(mcpPath, "utf8")
|
|
224
|
+
} catch (err) {
|
|
225
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
226
|
+
if (code === "ENOENT") return null // no MCP for this plugin — silent
|
|
227
|
+
logger.warn(
|
|
228
|
+
`could not read ".mcp.json" from plugin "${pluginId}" (${err instanceof Error ? err.message : String(err)}); skipping MCP for this plugin`,
|
|
229
|
+
)
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(raw) as unknown
|
|
235
|
+
if (
|
|
236
|
+
typeof parsed !== "object" ||
|
|
237
|
+
parsed === null ||
|
|
238
|
+
Array.isArray(parsed)
|
|
239
|
+
) {
|
|
240
|
+
logger.warn(
|
|
241
|
+
`".mcp.json" from plugin "${pluginId}" is not a JSON object; skipping MCP for this plugin`,
|
|
242
|
+
)
|
|
243
|
+
return null
|
|
244
|
+
}
|
|
245
|
+
return parsed as ClaudeMcpJson
|
|
246
|
+
} catch (err) {
|
|
247
|
+
logger.warn(
|
|
248
|
+
`could not parse ".mcp.json" from plugin "${pluginId}" (${err instanceof Error ? err.message : String(err)}); skipping MCP for this plugin`,
|
|
249
|
+
)
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Inject MCP servers from one plugin's `.mcp.json` into `mcpCfg`.
|
|
256
|
+
*/
|
|
257
|
+
async function injectPluginMcp(
|
|
258
|
+
plugin: ClaudePlugin,
|
|
259
|
+
mcpCfg: Record<string, McpEntry>,
|
|
260
|
+
allocator: NameAllocator,
|
|
261
|
+
summary: McpInjectionSummary,
|
|
262
|
+
logger: Logger,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const parsed = await readMcpJson(plugin.installPath, plugin.id, logger)
|
|
265
|
+
if (parsed === null) return
|
|
266
|
+
|
|
267
|
+
const servers = parsed.mcpServers
|
|
268
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
269
|
+
// .mcp.json exists but has no mcpServers — not an error, just nothing to inject.
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// pluginPart is loop-invariant — hoist outside the per-server loop.
|
|
274
|
+
const pluginPart = splitPluginId(plugin.id).plugin
|
|
275
|
+
|
|
276
|
+
for (const [serverName, serverDef] of Object.entries(servers)) {
|
|
277
|
+
if (typeof serverDef !== "object" || serverDef === null || Array.isArray(serverDef)) {
|
|
278
|
+
logger.warn(
|
|
279
|
+
`MCP server "${serverName}" in plugin "${plugin.id}" is not a valid object; skipping`,
|
|
280
|
+
)
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const mapped = mapClaudeMcpServer(serverDef as ClaudeMcpServer, plugin.installPath)
|
|
285
|
+
if (mapped === null) {
|
|
286
|
+
logger.warn(
|
|
287
|
+
`MCP server "${serverName}" in plugin "${plugin.id}" is missing required fields (url for http, command for local); skipping`,
|
|
288
|
+
)
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Base name is always "<plugin>-<server>" per §6.4 — the plugin prefix is intentional
|
|
293
|
+
// (every bridge-injected MCP name starts with the plugin name). On collision the
|
|
294
|
+
// NameAllocator ladder escalates from this already-prefixed base, so a double-prefix
|
|
295
|
+
// on collision (e.g. "<plugin>-<plugin>-<server>") is correct, not a bug.
|
|
296
|
+
// pluginPart is hoisted outside the loop (splitPluginId is loop-invariant).
|
|
297
|
+
const baseName = `${pluginPart}-${serverName}`
|
|
298
|
+
const { name, renamed } = allocator.claim(plugin.id, baseName)
|
|
299
|
+
|
|
300
|
+
mcpCfg[name] = mapped
|
|
301
|
+
summary.servers++
|
|
302
|
+
if (renamed) summary.renamed++
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/** Summary counters for MCP injection collected across all plugins. */
|
|
309
|
+
export interface McpInjectionSummary {
|
|
310
|
+
servers: number
|
|
311
|
+
renamed: number
|
|
312
|
+
skippedPolicy: number
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Count MCP servers in a plugin's `.mcp.json` without any validation or warnings.
|
|
317
|
+
* Used only for the policy-gate summary when `allowMcp` is off — a user who opted out
|
|
318
|
+
* should not see per-entry warnings from plugins they never enabled.
|
|
319
|
+
* Returns 0 if the file is absent, unreadable, or malformed.
|
|
320
|
+
*/
|
|
321
|
+
async function countMcpServersQuiet(installPath: string): Promise<number> {
|
|
322
|
+
const mcpPath = path.join(installPath, ".mcp.json")
|
|
323
|
+
try {
|
|
324
|
+
const raw = await fs.readFile(mcpPath, "utf8")
|
|
325
|
+
const parsed = JSON.parse(raw) as unknown
|
|
326
|
+
if (typeof parsed === "object" && parsed !== null && "mcpServers" in parsed) {
|
|
327
|
+
const servers = (parsed as { mcpServers?: unknown }).mcpServers
|
|
328
|
+
if (typeof servers === "object" && servers !== null && !Array.isArray(servers)) {
|
|
329
|
+
return Object.keys(servers).length
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return 0
|
|
333
|
+
} catch {
|
|
334
|
+
return 0
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Inject MCP servers from all `plugins` into the mutable `cfg`.
|
|
340
|
+
*
|
|
341
|
+
* When `allowMcp` is false, logs a policy summary and injects nothing.
|
|
342
|
+
* Builds one `NameAllocator` seeded with existing `cfg.mcp` keys.
|
|
343
|
+
* Processes plugins in the order given (caller passes sorted-by-id order).
|
|
344
|
+
*/
|
|
345
|
+
export async function injectMcp(
|
|
346
|
+
plugins: ClaudePlugin[],
|
|
347
|
+
cfg: Config,
|
|
348
|
+
allowMcp: boolean,
|
|
349
|
+
logger: Logger,
|
|
350
|
+
): Promise<McpInjectionSummary> {
|
|
351
|
+
const summary: McpInjectionSummary = { servers: 0, renamed: 0, skippedPolicy: 0 }
|
|
352
|
+
|
|
353
|
+
if (!allowMcp) {
|
|
354
|
+
// Policy gate: count potential servers without per-entry validation — a user who
|
|
355
|
+
// opted out of MCP should not see MCP-config warnings from plugins they never enabled.
|
|
356
|
+
let totalServers = 0
|
|
357
|
+
for (const plugin of plugins) {
|
|
358
|
+
totalServers += await countMcpServersQuiet(plugin.installPath)
|
|
359
|
+
}
|
|
360
|
+
summary.skippedPolicy = totalServers
|
|
361
|
+
if (totalServers > 0) {
|
|
362
|
+
logger.info(`skipped ${totalServers} MCP server(s) (policy: allowMcp is off)`)
|
|
363
|
+
}
|
|
364
|
+
return summary
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (plugins.length === 0) return summary
|
|
368
|
+
|
|
369
|
+
const mutableCfg = cfg as unknown as InjectableMcpConfig
|
|
370
|
+
const mcpCfg = guardMcpConfig(mutableCfg)
|
|
371
|
+
|
|
372
|
+
// Seed the allocator with existing cfg.mcp keys.
|
|
373
|
+
const existingMcp = new Set<string>(Object.keys(mcpCfg))
|
|
374
|
+
const allocator = new NameAllocator(existingMcp)
|
|
375
|
+
|
|
376
|
+
for (const plugin of plugins) {
|
|
377
|
+
await injectPluginMcp(plugin, mcpCfg, allocator, summary, logger)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return summary
|
|
381
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The result of a name-claim operation.
|
|
5
|
+
*
|
|
6
|
+
* @property name - The final allocated name (may be prefixed if the bare name collided).
|
|
7
|
+
* @property renamed - `true` when the bare name was not available and a prefix was applied.
|
|
8
|
+
*/
|
|
9
|
+
export interface ClaimResult {
|
|
10
|
+
readonly name: string
|
|
11
|
+
readonly renamed: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A family-agnostic name allocator that enforces the §7 no-shadowing invariant.
|
|
16
|
+
*
|
|
17
|
+
* Seed it with the set of already-existing names (native config items + built-ins + any
|
|
18
|
+
* names already registered by earlier allocator instances), then call `claim` for each
|
|
19
|
+
* component in the id-sorted plugin order that `selectEnabledPlugins` produces. The
|
|
20
|
+
* allocator accumulates claimed names so that later claims in the same run see all prior
|
|
21
|
+
* allocations — giving deterministic first-wins-by-id semantics across plugins.
|
|
22
|
+
*
|
|
23
|
+
* The rename ladder (§7), tried in order until a free slot is found:
|
|
24
|
+
* 1. `bareName`
|
|
25
|
+
* 2. `<plugin>-<bareName>`
|
|
26
|
+
* 3. `<marketplace>-<plugin>-<bareName>`
|
|
27
|
+
* 4. `<marketplace>-<plugin>-<bareName>-<shortHash>` (deterministic tiebreak)
|
|
28
|
+
*
|
|
29
|
+
* `<plugin>` and `<marketplace>` are derived by splitting the plugin id `name@marketplace`.
|
|
30
|
+
* The short hash is the first 8 hex characters of a SHA-256 over
|
|
31
|
+
* `${marketplace}/${plugin}/${bareName}`, so it is deterministic across runs for the same
|
|
32
|
+
* triple — a collision at rung 3 always resolves to the same name.
|
|
33
|
+
*
|
|
34
|
+
* Native/existing items are never renamed — only the bridge's claimant is prefixed.
|
|
35
|
+
*/
|
|
36
|
+
export class NameAllocator {
|
|
37
|
+
/** All names currently considered taken (native + previously claimed). */
|
|
38
|
+
private readonly taken: Set<string>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param existing - Names already in use (native config keys + built-in lists).
|
|
42
|
+
* A snapshot is taken; mutations to the passed set after construction are ignored.
|
|
43
|
+
*/
|
|
44
|
+
constructor(existing: ReadonlySet<string> | Iterable<string>) {
|
|
45
|
+
this.taken = new Set(existing)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attempt to allocate `bareName` on behalf of `pluginId`.
|
|
50
|
+
*
|
|
51
|
+
* `pluginId` must be in `name@marketplace` format. If the bare name is already taken,
|
|
52
|
+
* the rename ladder is walked until a free slot is found (the hash rung is the final
|
|
53
|
+
* fallback and always produces a unique name). The winning name is recorded as taken so
|
|
54
|
+
* subsequent claims see it.
|
|
55
|
+
*/
|
|
56
|
+
claim(pluginId: string, bareName: string): ClaimResult {
|
|
57
|
+
const { plugin, marketplace } = splitPluginId(pluginId)
|
|
58
|
+
|
|
59
|
+
// Rung 1: bare name
|
|
60
|
+
if (!this.taken.has(bareName)) {
|
|
61
|
+
this.taken.add(bareName)
|
|
62
|
+
return { name: bareName, renamed: false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Rung 2: <plugin>-<bareName>
|
|
66
|
+
const rung2 = `${plugin}-${bareName}`
|
|
67
|
+
if (!this.taken.has(rung2)) {
|
|
68
|
+
this.taken.add(rung2)
|
|
69
|
+
return { name: rung2, renamed: true }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Rung 3: <marketplace>-<plugin>-<bareName>
|
|
73
|
+
const rung3 = `${marketplace}-${plugin}-${bareName}`
|
|
74
|
+
if (!this.taken.has(rung3)) {
|
|
75
|
+
this.taken.add(rung3)
|
|
76
|
+
return { name: rung3, renamed: true }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Rung 4: <marketplace>-<plugin>-<bareName>-<shortHash> (deterministic final tiebreak)
|
|
80
|
+
const hash = shortHash(marketplace, plugin, bareName)
|
|
81
|
+
const rung4 = `${marketplace}-${plugin}-${bareName}-${hash}`
|
|
82
|
+
this.taken.add(rung4)
|
|
83
|
+
return { name: rung4, renamed: true }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Return a snapshot of all currently-taken names, including everything registered
|
|
88
|
+
* by prior `claim` calls. Useful for seeding a child allocator or for inspection.
|
|
89
|
+
*/
|
|
90
|
+
snapshot(): ReadonlySet<string> {
|
|
91
|
+
return new Set(this.taken)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Split a plugin id of the form `name@marketplace` into its two parts.
|
|
97
|
+
*
|
|
98
|
+
* If the id is malformed (no `@`), the whole string is used as the plugin part
|
|
99
|
+
* and marketplace defaults to `"unknown"` — matching how the bridge logs unknown
|
|
100
|
+
* marketplace entries rather than hard-failing.
|
|
101
|
+
*/
|
|
102
|
+
export function splitPluginId(pluginId: string): { plugin: string; marketplace: string } {
|
|
103
|
+
const at = pluginId.indexOf("@")
|
|
104
|
+
if (at === -1) return { plugin: pluginId, marketplace: "unknown" }
|
|
105
|
+
return {
|
|
106
|
+
plugin: pluginId.slice(0, at),
|
|
107
|
+
marketplace: pluginId.slice(at + 1),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Compute the 8-hex-character short hash used in rung-4 tiebreak names.
|
|
113
|
+
*
|
|
114
|
+
* Deterministic: for the same `(marketplace, plugin, bareName)` triple this always
|
|
115
|
+
* returns the same string, across runs and machines. Uses SHA-256 from `node:crypto`.
|
|
116
|
+
*/
|
|
117
|
+
export function shortHash(marketplace: string, plugin: string, bareName: string): string {
|
|
118
|
+
return createHash("sha256")
|
|
119
|
+
.update(`${marketplace}/${plugin}/${bareName}`)
|
|
120
|
+
.digest("hex")
|
|
121
|
+
.slice(0, 8)
|
|
122
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version-pinned OpenCode built-in name lists and skill-discovery constants.
|
|
3
|
+
*
|
|
4
|
+
* The runtime OpenCode version verified by the spec is **1.15.10**; the source
|
|
5
|
+
* checkout on this machine is **1.15.13**. The constants were re-confirmed
|
|
6
|
+
* unchanged against the 1.15.13 source — the cited line numbers and values are
|
|
7
|
+
* identical in both versions. The pin below records the runtime version (what
|
|
8
|
+
* users run and what the spec validated against); re-validate on the next upgrade.
|
|
9
|
+
*
|
|
10
|
+
* MUST be re-validated on every OpenCode upgrade — these are part of what the
|
|
11
|
+
* §9 compatibility check guards.
|
|
12
|
+
*
|
|
13
|
+
* Source locations (all in `packages/opencode/src/`):
|
|
14
|
+
* - Built-in agents: `agent/agent.ts:127-248`
|
|
15
|
+
* - Built-in commands: `command/index.ts:53-110` (`Default.INIT`, `Default.REVIEW`)
|
|
16
|
+
* - Built-in skills: `skill/index.ts:33`
|
|
17
|
+
* - Skill scan dirs: `skill/index.ts:22-26, 173-233`
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// The single source of truth for the verified OpenCode version is VERIFIED_OPENCODE_VERSION
|
|
21
|
+
// in src/version.ts. Re-export it here so callers in this module don't need a cross-module
|
|
22
|
+
// import, and so the value is never duplicated.
|
|
23
|
+
export { VERIFIED_OPENCODE_VERSION as PINNED_OPENCODE_VERSION } from "./version.js"
|
|
24
|
+
|
|
25
|
+
// ── Built-in agents ─────────────────────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// Registered as hard-coded entries in the `agents` object before `cfg.agent`
|
|
28
|
+
// is merged in (`agent/agent.ts:127-248`). They are NOT present in `cfg.agent`
|
|
29
|
+
// at hook time, so the bridge must treat them as already-taken names.
|
|
30
|
+
|
|
31
|
+
/** Built-in OpenCode agent names (not present in cfg at hook time). */
|
|
32
|
+
export const BUILTIN_AGENT_NAMES: ReadonlySet<string> = new Set([
|
|
33
|
+
"build", // agent/agent.ts:128
|
|
34
|
+
"plan", // agent/agent.ts:143
|
|
35
|
+
"general", // agent/agent.ts:166
|
|
36
|
+
"explore", // agent/agent.ts:180
|
|
37
|
+
"compaction", // agent/agent.ts:203 (hidden/internal)
|
|
38
|
+
"title", // agent/agent.ts:218 (hidden/internal)
|
|
39
|
+
"summary", // agent/agent.ts:234 (hidden/internal)
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
// ── Built-in commands ────────────────────────────────────────────────────────
|
|
43
|
+
//
|
|
44
|
+
// Registered before `cfg.command` entries are merged in (`command/index.ts:53-110`).
|
|
45
|
+
// `Default.INIT = "init"`, `Default.REVIEW = "review"`.
|
|
46
|
+
|
|
47
|
+
/** Built-in OpenCode command names (not present in cfg at hook time). */
|
|
48
|
+
export const BUILTIN_COMMAND_NAMES: ReadonlySet<string> = new Set([
|
|
49
|
+
"init", // command/index.ts:53-55, 77-85
|
|
50
|
+
"review", // command/index.ts:53-55, 86-95
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
// ── Built-in skills ──────────────────────────────────────────────────────────
|
|
54
|
+
//
|
|
55
|
+
// The only built-in skill is defined at `skill/index.ts:33`.
|
|
56
|
+
// Unlike agents/commands, a file-based skill of the same name *overrides* the
|
|
57
|
+
// built-in (not the other way around), but the bridge still avoids shadowing it.
|
|
58
|
+
|
|
59
|
+
/** Built-in OpenCode skill names (not present in cfg.skills.paths at hook time). */
|
|
60
|
+
export const BUILTIN_SKILL_NAMES: ReadonlySet<string> = new Set([
|
|
61
|
+
"customize-opencode", // skill/index.ts:33
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
// ── Skill-discovery dir specs ────────────────────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// OpenCode discovers SKILL.md files from two families of roots:
|
|
67
|
+
//
|
|
68
|
+
// 1. External roots (`.claude` + `.agents`): global home dirs + project-upward walk
|
|
69
|
+
// Pattern: `skills/**/SKILL.md` (`skill/index.ts:24`)
|
|
70
|
+
// 2. OpenCode config dirs: global XDG config + project-upward `.opencode`
|
|
71
|
+
// Pattern: `{skill,skills}/**/SKILL.md` (`skill/index.ts:25`)
|
|
72
|
+
// 3. `cfg.skills.paths` entries: explicit user-specified paths
|
|
73
|
+
// Pattern: `**/SKILL.md` (`skill/index.ts:26, 219`)
|
|
74
|
+
//
|
|
75
|
+
// The `.agents` root is for skills only — `.opencode/agents` is NOT a skill dir.
|
|
76
|
+
// Source: `skill/index.ts:22-26, 173-233`
|
|
77
|
+
|
|
78
|
+
/** Glob pattern used under `.claude` and `.agents` external roots. */
|
|
79
|
+
export const EXTERNAL_SKILL_GLOB = "skills/**/SKILL.md"
|
|
80
|
+
|
|
81
|
+
/** Glob pattern used under OpenCode config dirs (`.opencode`, `~/.config/opencode`). */
|
|
82
|
+
export const OPENCODE_SKILL_GLOB = "{skill,skills}/**/SKILL.md"
|
|
83
|
+
|
|
84
|
+
/** Glob pattern used under explicit `cfg.skills.paths` entries. */
|
|
85
|
+
export const PATHS_SKILL_GLOB = "**/SKILL.md"
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* External root dir names walked from `$HOME` and project-upward.
|
|
89
|
+
* Source: `skill/index.ts:22-24, 185-201`
|
|
90
|
+
*/
|
|
91
|
+
export const EXTERNAL_SKILL_ROOTS = [".claude", ".agents"] as const
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* OpenCode config dir name walked project-upward (and from `$HOME`).
|
|
95
|
+
* Also used as part of the global XDG config path (`~/.config/opencode`).
|
|
96
|
+
* Source: `skill/index.ts:205-207`, `config/paths.ts:23-41`
|
|
97
|
+
*/
|
|
98
|
+
export const OPENCODE_CONFIG_DIR_NAME = ".opencode"
|