@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.
@@ -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"