@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,122 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import type { PluginInput } from "@opencode-ai/plugin"
4
+ import type { Logger } from "./logger.js"
5
+ import { type BridgeConfig, type ClaudePlugin } from "./types.js"
6
+
7
+ /** The Bun shell handle provided to plugins (`input.$`); not exported by name from the package. */
8
+ type BunShell = PluginInput["$"]
9
+
10
+ /** Minimal duck-type guard for one `claude plugin list --json` entry. */
11
+ function isClaudePlugin(value: unknown): value is ClaudePlugin {
12
+ if (typeof value !== "object" || value === null) return false
13
+ const v = value as Record<string, unknown>
14
+ const projectPath = v["projectPath"]
15
+ return (
16
+ typeof v["id"] === "string" &&
17
+ typeof v["installPath"] === "string" &&
18
+ typeof v["enabled"] === "boolean" &&
19
+ (v["scope"] === "user" || v["scope"] === "project" || v["scope"] === "local") &&
20
+ // Guard projectPath so a non-string value can't later reach path.resolve and throw.
21
+ (projectPath === undefined || projectPath === null || typeof projectPath === "string")
22
+ )
23
+ }
24
+
25
+ /**
26
+ * Run `claude plugin list --json` via the plugin's Bun shell and parse the result.
27
+ * Returns `null` (and warns) if the CLI is absent, exits non-zero, or emits unparseable
28
+ * output — the hook then injects nothing but still succeeds (design §10). The warning is
29
+ * strict-promotable, so `strict` turns a missing CLI into a hard error.
30
+ */
31
+ export async function listClaudePlugins($: BunShell, logger: Logger): Promise<ClaudePlugin[] | null> {
32
+ let raw: string
33
+ try {
34
+ const out = await $`claude plugin list --json`.quiet().nothrow()
35
+ if (out.exitCode !== 0) {
36
+ logger.warn(
37
+ `\`claude plugin list --json\` exited ${out.exitCode}; injecting nothing this run`,
38
+ )
39
+ return null
40
+ }
41
+ raw = out.stdout.toString()
42
+ } catch (err) {
43
+ logger.warn(
44
+ `could not run the \`claude\` CLI (${err instanceof Error ? err.message : String(err)}); is it on PATH? injecting nothing this run`,
45
+ )
46
+ return null
47
+ }
48
+
49
+ let parsed: unknown
50
+ try {
51
+ parsed = JSON.parse(raw)
52
+ } catch {
53
+ logger.warn(`could not parse \`claude plugin list --json\` output as JSON; injecting nothing this run`)
54
+ return null
55
+ }
56
+ if (!Array.isArray(parsed)) {
57
+ logger.warn(`unexpected \`claude plugin list --json\` output (not an array); injecting nothing this run`)
58
+ return null
59
+ }
60
+
61
+ const plugins: ClaudePlugin[] = []
62
+ for (const entry of parsed) {
63
+ if (isClaudePlugin(entry)) plugins.push(entry)
64
+ // A malformed entry is treated like a component parse failure (§10): skip it and warn,
65
+ // and let `strict` promote it to a hard error (default fatalInStrict). This is deliberate
66
+ // and consistent with how strict treats other parse failures.
67
+ else logger.warn(`skipping a malformed \`claude plugin list\` entry`)
68
+ }
69
+ return plugins
70
+ }
71
+
72
+ /**
73
+ * Resolve a path to its canonical form, following symlinks. Falls back to
74
+ * `path.resolve` when the path does not exist (e.g. a deleted project dir
75
+ * stored in an old `claude plugin list` entry), so the result is always a
76
+ * deterministic string and never throws.
77
+ */
78
+ function realpathOrResolve(p: string): string {
79
+ try {
80
+ return fs.realpathSync(p)
81
+ } catch {
82
+ return path.resolve(p)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * True when two filesystem paths refer to the same directory.
88
+ *
89
+ * Compares canonical (realpath) forms first so symlinked project paths
90
+ * (common on macOS where `/tmp` → `/private/tmp`, and Linux dev setups)
91
+ * are correctly identified as equal. Falls back to lexical `path.resolve`
92
+ * comparison as a secondary check when one side doesn't exist yet.
93
+ */
94
+ export function samePath(a: string | null | undefined, b: string): boolean {
95
+ if (!a) return false
96
+ return realpathOrResolve(a) === realpathOrResolve(b) || path.resolve(a) === path.resolve(b)
97
+ }
98
+
99
+ /**
100
+ * Apply the `mirror-claude` selection predicate (§5): an entry is selected when it is
101
+ * enabled, not blocked, and either `user`-scoped (global) or bound to the current project.
102
+ * The result is de-duplicated by `id` and sorted by `id` ascending so downstream naming is
103
+ * deterministic regardless of `claude plugin list` ordering (§7).
104
+ */
105
+ export function selectEnabledPlugins(
106
+ plugins: ClaudePlugin[],
107
+ config: BridgeConfig,
108
+ cwd: string,
109
+ ): ClaudePlugin[] {
110
+ const blocked = new Set(config.blockedPlugins)
111
+ const byId = new Map<string, ClaudePlugin>()
112
+
113
+ for (const p of plugins) {
114
+ if (!p.enabled) continue
115
+ if (blocked.has(p.id)) continue
116
+ const inScope = p.scope === "user" || samePath(p.projectPath, cwd)
117
+ if (!inScope) continue
118
+ if (!byId.has(p.id)) byId.set(p.id, p)
119
+ }
120
+
121
+ return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
122
+ }
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Skills injection from enabled Claude plugins into OpenCode's `cfg.skills.paths`.
3
+ *
4
+ * Skills have no inline form: OpenCode discovers them from directories, naming each
5
+ * skill from the `SKILL.md` frontmatter `name`. For each enabled plugin (in sorted-by-id
6
+ * order), we scan `<installPath>/skills/` for skill subdirectories (each containing a
7
+ * `SKILL.md`). Then:
8
+ *
9
+ * - If the skill's bare name is free: push the individual skill dir directly onto
10
+ * `cfg.skills.paths` — zero files copied.
11
+ * - If the name collides: copy the whole skill dir into the bridge cache, patch the
12
+ * copy's `SKILL.md` frontmatter `name` to the prefixed name, then push the copy.
13
+ *
14
+ * The bridge cache lives at `~/.cache/opencode-claude-bridge/skills/` (override via
15
+ * `cacheRoot` option for hermetic tests). Each copy is keyed by
16
+ * `<marketplace>/<plugin>/<version>/<allocatedName>` and regenerated when the source is
17
+ * newer. Stale version directories (from prior plugin upgrades) are pruned on each run.
18
+ *
19
+ * Design constraints:
20
+ * - Do NOT call `Skill.Service.all()` or any OpenCode Skill service — it would force the
21
+ * lazy skill cache to populate before our injected `cfg.skills.paths` (§6.3).
22
+ * - Use `collectExistingSkillNames` (fs-scan) for existing-name detection only.
23
+ * - Zero runtime npm dependencies — `node:*` only.
24
+ */
25
+
26
+ import fs from "node:fs/promises"
27
+ import os from "node:os"
28
+ import path from "node:path"
29
+ import type { Config } from "@opencode-ai/plugin"
30
+ import { extractSkillName } from "./skill-scan.js"
31
+ import { NameAllocator, splitPluginId } from "./naming.js"
32
+ import type { Logger } from "./logger.js"
33
+ import type { ClaudePlugin } from "./types.js"
34
+
35
+ // ── Config shape guard ────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * The minimal view of `cfg.skills` that we read and mutate. OpenCode's V1 shape
39
+ * has both `paths` and `urls` as Schema.optional (either can be absent at hook
40
+ * time — verified against packages/core/src/v1/config/skills.ts).
41
+ *
42
+ * The real `Config` type from `@opencode-ai/plugin` uses complex Effect/Schema
43
+ * types; we cast to this interface internally (verified empirically — Appendix A).
44
+ */
45
+ interface SkillsConfig {
46
+ paths?: string[]
47
+ urls?: string[]
48
+ }
49
+
50
+ /** Normalized skills config with both arrays guaranteed non-null. */
51
+ interface NormalizedSkillsConfig {
52
+ paths: string[]
53
+ urls: string[]
54
+ }
55
+
56
+ interface InjectableConfig {
57
+ skills?: SkillsConfig | boolean | undefined
58
+ }
59
+
60
+ /**
61
+ * Ensure `cfg.skills` is a plain `{ paths, urls }` object with both arrays
62
+ * initialized. Guards `undefined`/`null`/boolean values AND independently
63
+ * fills in missing `paths` or `urls` — both fields are `Schema.optional` in
64
+ * OpenCode V1, so a real user config of `{ "skills": { "paths": ["x"] } }`
65
+ * (no `urls`) is valid and must not throw (Appendix A).
66
+ *
67
+ * Returns the normalized reference with both arrays guaranteed present.
68
+ */
69
+ function guardSkillsConfig(cfg: InjectableConfig): NormalizedSkillsConfig {
70
+ if (
71
+ cfg.skills === undefined ||
72
+ cfg.skills === null ||
73
+ typeof cfg.skills === "boolean"
74
+ ) {
75
+ cfg.skills = { paths: [], urls: [] }
76
+ } else {
77
+ // Both fields are optional in the V1 schema; initialize each independently
78
+ // so a config that has only one of them does not throw on the other.
79
+ const s = cfg.skills as SkillsConfig
80
+ if (!Array.isArray(s.paths)) s.paths = []
81
+ if (!Array.isArray(s.urls)) s.urls = []
82
+ }
83
+ return cfg.skills as NormalizedSkillsConfig
84
+ }
85
+
86
+ // ── Directory utilities ───────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * List the immediate subdirectory names of `dir`. Returns an empty array when the
90
+ * directory is absent or inaccessible — callers treat a missing skills/ dir as "no
91
+ * skills to inject" for this plugin.
92
+ */
93
+ async function listSubdirs(dir: string): Promise<string[]> {
94
+ let entries: import("node:fs").Dirent[]
95
+ try {
96
+ entries = await fs.readdir(dir, { withFileTypes: true })
97
+ } catch {
98
+ return []
99
+ }
100
+ return entries
101
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
102
+ .map((e) => e.name)
103
+ }
104
+
105
+ /**
106
+ * Recursively copy `srcDir` to `dstDir`, excluding dot-directories (`.git`, etc.)
107
+ * and any symlinks. Asset files (images, data files, etc.) are copied verbatim so
108
+ * relative references in SKILL.md survive. `dstDir` and all parents are created if
109
+ * they do not exist.
110
+ *
111
+ * Symlinks are skipped intentionally: `fs.copyFile` follows symlinks and copies the
112
+ * target content, which could exfiltrate arbitrary files (e.g. a skill shipping
113
+ * `evil-link → ~/.aws/credentials`) into the bridge cache. Legitimate skill assets
114
+ * do not need symlinks.
115
+ */
116
+ async function copyDirRecursive(srcDir: string, dstDir: string, logger: Logger): Promise<void> {
117
+ await fs.mkdir(dstDir, { recursive: true })
118
+
119
+ let entries: import("node:fs").Dirent[]
120
+ try {
121
+ entries = await fs.readdir(srcDir, { withFileTypes: true })
122
+ } catch {
123
+ return
124
+ }
125
+
126
+ for (const entry of entries) {
127
+ // Exclude dot-directories (e.g. .git, .github) — but do copy dot-files.
128
+ if (entry.isDirectory() && entry.name.startsWith(".")) continue
129
+
130
+ const srcPath = path.join(srcDir, entry.name)
131
+ const dstPath = path.join(dstDir, entry.name)
132
+
133
+ if (entry.isDirectory()) {
134
+ await copyDirRecursive(srcPath, dstPath, logger)
135
+ } else if (entry.isFile()) {
136
+ await fs.copyFile(srcPath, dstPath)
137
+ } else if (entry.isSymbolicLink()) {
138
+ // Skip symlinks — following them could copy arbitrary host files into the
139
+ // bridge cache (a skill could ship a symlink targeting sensitive paths).
140
+ logger.warn(`skipped symlink "${srcPath}" in skill cache copy`, { fatalInStrict: false })
141
+ }
142
+ }
143
+ }
144
+
145
+ // ── Path segment sanitization ─────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Sanitize a single path segment so a hostile plugin id/version cannot escape
149
+ * the cache root. Rules:
150
+ * - Replace every `/` (path separator) with `_`
151
+ * - Replace every `..` component with `__` (prevents parent traversal)
152
+ * - Strip a leading `.` (prevents hidden-file names in the cache dir)
153
+ * - Replace every `\` (Windows path separator) with `_`
154
+ *
155
+ * The input is always a non-empty string; the output is a safe, flat filename.
156
+ */
157
+ export function sanitizeCacheSegment(segment: string): string {
158
+ return segment
159
+ .replace(/\\/g, "_") // Windows path separators
160
+ .replace(/\//g, "_") // Unix path separators
161
+ .replace(/\.\./g, "__") // parent-traversal sequences
162
+ .replace(/^\./, "_") // leading dot → hidden file prevention
163
+ }
164
+
165
+ // ── Cache key / staleness ─────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Return the bridge-cache directory for a specific (plugin, skill) combination.
169
+ *
170
+ * Key: `<cacheRoot>/<marketplace>/<plugin>/<version>/<allocatedName>/`
171
+ *
172
+ * `<marketplace>` and `<plugin>` are derived by splitting the plugin id via
173
+ * `splitPluginId`. Every segment is sanitized defensively so a hostile id
174
+ * cannot escape the cache root via path traversal.
175
+ */
176
+ function cacheDirForSkill(cacheRoot: string, plugin: ClaudePlugin, skillName: string): string {
177
+ const { plugin: pluginPart, marketplace } = splitPluginId(plugin.id)
178
+ return path.join(
179
+ cacheRoot,
180
+ sanitizeCacheSegment(marketplace),
181
+ sanitizeCacheSegment(pluginPart),
182
+ sanitizeCacheSegment(plugin.version),
183
+ skillName,
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Prune stale version directories under `<cacheRoot>/<marketplace>/<plugin>/`.
189
+ *
190
+ * After writing the current-version directory, any sibling `<other-version>/`
191
+ * directories that belong to the SAME plugin (same marketplace + plugin segment)
192
+ * but carry a different version string are removed. This cleans up copies left
193
+ * by prior plugin upgrades.
194
+ *
195
+ * Only other-version dirs are touched — neighboring marketplace or plugin dirs
196
+ * are never affected. GC failures are skip+warn, never thrown.
197
+ */
198
+ async function gcOldVersionDirs(
199
+ cacheRoot: string,
200
+ plugin: ClaudePlugin,
201
+ currentVersion: string,
202
+ logger: Logger,
203
+ ): Promise<void> {
204
+ const { plugin: pluginPart, marketplace } = splitPluginId(plugin.id)
205
+ const pluginCacheDir = path.join(
206
+ cacheRoot,
207
+ sanitizeCacheSegment(marketplace),
208
+ sanitizeCacheSegment(pluginPart),
209
+ )
210
+
211
+ let entries: import("node:fs").Dirent[]
212
+ try {
213
+ entries = await fs.readdir(pluginCacheDir, { withFileTypes: true })
214
+ } catch {
215
+ return // directory does not exist yet — nothing to GC
216
+ }
217
+
218
+ const safeCurrentVersion = sanitizeCacheSegment(currentVersion)
219
+ for (const entry of entries) {
220
+ if (!entry.isDirectory()) continue
221
+ if (entry.name === safeCurrentVersion) continue // keep current version
222
+
223
+ const staleDir = path.join(pluginCacheDir, entry.name)
224
+ try {
225
+ await fs.rm(staleDir, { recursive: true, force: true })
226
+ logger.info(`pruned stale cache version "${staleDir}" for plugin "${plugin.id}"`)
227
+ } catch (err) {
228
+ logger.warn(
229
+ `could not prune stale cache version "${staleDir}" for plugin "${plugin.id}" (${err instanceof Error ? err.message : String(err)}); skipping`,
230
+ { fatalInStrict: false },
231
+ )
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Return `true` when the cached copy needs to be (re-)generated.
238
+ *
239
+ * The copy is stale (and must be regenerated) when:
240
+ * - The cache directory does not exist at all, OR
241
+ * - The source `SKILL.md` is newer than the cached `SKILL.md`.
242
+ *
243
+ * "Missing or stale" covers both first-run (always copy) and version-change
244
+ * (cache path changes per version, so old copies are silently abandoned).
245
+ */
246
+ async function isCacheStale(srcSkillMd: string, cachedSkillMd: string): Promise<boolean> {
247
+ let cachedStat: Awaited<ReturnType<typeof fs.stat>>
248
+ try {
249
+ cachedStat = await fs.stat(cachedSkillMd)
250
+ } catch {
251
+ return true // cache does not exist
252
+ }
253
+
254
+ let srcStat: Awaited<ReturnType<typeof fs.stat>>
255
+ try {
256
+ srcStat = await fs.stat(srcSkillMd)
257
+ } catch {
258
+ return true // cannot stat source — treat as stale so the caller will warn
259
+ }
260
+
261
+ return srcStat.mtimeMs > cachedStat.mtimeMs
262
+ }
263
+
264
+ // ── Frontmatter name patching ─────────────────────────────────────────────────
265
+
266
+ /**
267
+ * Rewrite the `name:` field in a `SKILL.md` file's YAML frontmatter to `newName`,
268
+ * preserving all other content verbatim.
269
+ *
270
+ * The patch is targeted: only the first non-indented `name:` line inside the
271
+ * frontmatter block is replaced. The rest of the file (including body text, asset
272
+ * references, and unknown frontmatter keys) is left byte-identical to the source.
273
+ *
274
+ * Returns the patched content string, or throws if the frontmatter is malformed
275
+ * (no opening fence, no closing fence, or no top-level `name:` line to replace).
276
+ */
277
+ export function patchSkillName(content: string, newName: string): string {
278
+ const lines = content.split(/\r?\n/)
279
+
280
+ if (lines[0]?.trim() !== "---") {
281
+ throw new Error("SKILL.md does not start with a frontmatter fence")
282
+ }
283
+
284
+ let closingIdx = -1
285
+ for (let i = 1; i < lines.length; i++) {
286
+ if (lines[i]?.trim() === "---") {
287
+ closingIdx = i
288
+ break
289
+ }
290
+ }
291
+ if (closingIdx === -1) {
292
+ throw new Error("SKILL.md frontmatter is not closed")
293
+ }
294
+
295
+ // Find and replace the first non-indented `name:` line in the frontmatter.
296
+ let patched = false
297
+ for (let i = 1; i < closingIdx; i++) {
298
+ const line = lines[i]!
299
+ if (/^name\s*:/.test(line)) {
300
+ lines[i] = `name: ${newName}`
301
+ patched = true
302
+ break
303
+ }
304
+ }
305
+
306
+ if (!patched) {
307
+ throw new Error("SKILL.md frontmatter has no top-level `name:` field to patch")
308
+ }
309
+
310
+ // Reassemble using the original line endings. If the content used CRLF we split
311
+ // on CRLF/LF above; rejoin with the original separator of the first line.
312
+ const sep = content.includes("\r\n") ? "\r\n" : "\n"
313
+ return lines.join(sep)
314
+ }
315
+
316
+ // ── Per-plugin injection ──────────────────────────────────────────────────────
317
+
318
+ /**
319
+ * Scan `<installPath>/skills/` for skill subdirectories and inject each one into
320
+ * `cfg.skills.paths`, performing a cache copy + frontmatter patch on collision.
321
+ *
322
+ * @returns the number of skills injected and collisions renamed.
323
+ */
324
+ async function injectPluginSkills(
325
+ plugin: ClaudePlugin,
326
+ skillsCfg: NormalizedSkillsConfig,
327
+ allocator: NameAllocator,
328
+ cacheRoot: string,
329
+ summary: SkillInjectionSummary,
330
+ logger: Logger,
331
+ ): Promise<void> {
332
+ const pluginSkillsDir = path.join(plugin.installPath, "skills")
333
+ const subdirs = await listSubdirs(pluginSkillsDir)
334
+ if (subdirs.length === 0) return
335
+
336
+ // GC stale version directories for this plugin once per injection run,
337
+ // before materializing any new copies. This removes copies left by previous
338
+ // plugin versions. Failure is non-fatal (skip+warn).
339
+ await gcOldVersionDirs(cacheRoot, plugin, plugin.version, logger)
340
+
341
+ for (const subdir of subdirs) {
342
+ const skillDir = path.join(pluginSkillsDir, subdir)
343
+ const skillMdPath = path.join(skillDir, "SKILL.md")
344
+
345
+ // Read and parse the skill name from frontmatter.
346
+ let content: string
347
+ try {
348
+ content = await fs.readFile(skillMdPath, "utf8")
349
+ } catch (err) {
350
+ logger.warn(
351
+ `could not read SKILL.md at "${skillMdPath}" from plugin "${plugin.id}" (${err instanceof Error ? err.message : String(err)}); skipping`,
352
+ )
353
+ continue
354
+ }
355
+
356
+ const bareName = extractSkillName(content)
357
+ if (bareName === null) {
358
+ logger.warn(
359
+ `SKILL.md at "${skillMdPath}" from plugin "${plugin.id}" has no frontmatter name; skipping`,
360
+ )
361
+ continue
362
+ }
363
+
364
+ const { name: allocatedName, renamed } = allocator.claim(plugin.id, bareName)
365
+
366
+ if (!renamed) {
367
+ // No collision — point OpenCode directly at the plugin's skill dir.
368
+ skillsCfg.paths.push(skillDir)
369
+ summary.skills++
370
+ } else {
371
+ // Collision — copy the skill dir into the bridge cache and patch the name.
372
+ const cachedSkillDir = cacheDirForSkill(cacheRoot, plugin, allocatedName)
373
+ const cachedSkillMd = path.join(cachedSkillDir, "SKILL.md")
374
+
375
+ const stale = await isCacheStale(skillMdPath, cachedSkillMd)
376
+ if (stale) {
377
+ try {
378
+ await copyDirRecursive(skillDir, cachedSkillDir, logger)
379
+ // Patch using the content already in memory (read above for extractSkillName)
380
+ // rather than re-reading the just-copied file — same bytes, avoids a round-trip.
381
+ const patched = patchSkillName(content, allocatedName)
382
+ await fs.writeFile(cachedSkillMd, patched, "utf8")
383
+ } catch (err) {
384
+ logger.warn(
385
+ `failed to create bridge-cache copy for skill "${bareName}" from plugin "${plugin.id}" (${err instanceof Error ? err.message : String(err)}); skipping`,
386
+ )
387
+ continue
388
+ }
389
+ }
390
+
391
+ skillsCfg.paths.push(cachedSkillDir)
392
+ summary.skills++
393
+ summary.renamed++
394
+ }
395
+ }
396
+ }
397
+
398
+ // ── Public API ────────────────────────────────────────────────────────────────
399
+
400
+ /** Summary counters for skills injection collected across all plugins. */
401
+ export interface SkillInjectionSummary {
402
+ skills: number
403
+ renamed: number
404
+ }
405
+
406
+ /**
407
+ * Options for {@link injectSkills}.
408
+ */
409
+ export interface SkillInjectOptions {
410
+ /**
411
+ * The user's home directory. Used for `collectExistingSkillNames` and for
412
+ * the default bridge-cache path (`~/.cache/opencode-claude-bridge/skills/`).
413
+ */
414
+ home: string
415
+ /**
416
+ * The current project directory (OpenCode's instance directory).
417
+ * Used as the starting point for project-upward skill-name scans.
418
+ */
419
+ projectDir: string
420
+ /**
421
+ * Override the bridge-cache root for hermetic tests. When omitted, defaults
422
+ * to `~/.cache/opencode-claude-bridge/skills/`.
423
+ */
424
+ cacheRoot?: string
425
+ }
426
+
427
+ /**
428
+ * Inject skills from all `plugins` into the mutable `cfg`.
429
+ *
430
+ * Builds a `NameAllocator` seeded with the union of existing skill names
431
+ * (from the fs scan via `collectExistingSkillNames`) so injected skills never
432
+ * shadow native/existing items. Processes plugins in the order given (callers
433
+ * must pass the sorted-by-id order from `selectEnabledPlugins`).
434
+ *
435
+ * Emits a warning if `cfg.skills.urls` is non-empty (URL-sourced skill collision
436
+ * detection is not possible at hook time — §6.3).
437
+ *
438
+ * Accepts the SDK `Config` type and casts it internally. The runtime shape is a
439
+ * plain mutable object (verified empirically, Appendix A).
440
+ *
441
+ * A skill whose SKILL.md can't be read/parsed, or whose collision-copy fails, is
442
+ * skipped with a warning; the hook still never throws in non-strict mode.
443
+ */
444
+ export async function injectSkills(
445
+ plugins: ClaudePlugin[],
446
+ cfg: Config,
447
+ existingSkillNames: ReadonlySet<string>,
448
+ opts: SkillInjectOptions,
449
+ logger: Logger,
450
+ ): Promise<SkillInjectionSummary> {
451
+ const mutableCfg = cfg as unknown as InjectableConfig
452
+ const summary: SkillInjectionSummary = { skills: 0, renamed: 0 }
453
+
454
+ if (plugins.length === 0) return summary
455
+
456
+ const skillsCfg = guardSkillsConfig(mutableCfg)
457
+
458
+ // Warn about URL-sourced skills — their names are not knowable at hook time
459
+ // without triggering the lazy Skill-service fetch (§6.3).
460
+ if (skillsCfg.urls.length > 0) {
461
+ logger.warn(
462
+ "cfg.skills.urls is non-empty; URL-sourced skill names are not available at hook time — bridge cannot detect collisions against URL-sourced skills",
463
+ { fatalInStrict: false },
464
+ )
465
+ }
466
+
467
+ const cacheRoot =
468
+ opts.cacheRoot ?? path.join(opts.home, ".cache", "opencode-claude-bridge", "skills")
469
+
470
+ // Seed the allocator with every name OpenCode already knows about (native + built-ins).
471
+ // `existingSkillNames` is provided by the caller (from `collectExistingSkillNames`) to
472
+ // avoid calling the Skill service from the hook.
473
+ const allocator = new NameAllocator(existingSkillNames)
474
+
475
+ for (const plugin of plugins) {
476
+ await injectPluginSkills(plugin, skillsCfg, allocator, cacheRoot, summary, logger)
477
+ }
478
+
479
+ return summary
480
+ }