@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/selection.ts
ADDED
|
@@ -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
|
+
}
|