@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,349 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
BUILTIN_SKILL_NAMES,
|
|
5
|
+
EXTERNAL_SKILL_GLOB,
|
|
6
|
+
EXTERNAL_SKILL_ROOTS,
|
|
7
|
+
OPENCODE_CONFIG_DIR_NAME,
|
|
8
|
+
OPENCODE_SKILL_GLOB,
|
|
9
|
+
PATHS_SKILL_GLOB,
|
|
10
|
+
} from "./opencode-builtins.js"
|
|
11
|
+
import { locateFrontmatter, FRONTMATTER_PARSE_ERROR } from "./frontmatter.js"
|
|
12
|
+
|
|
13
|
+
// ── Skill-name extractor ──────────────────────────────────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// OpenCode uses `gray-matter` for full YAML parsing; the bridge needs only the
|
|
16
|
+
// `name` field. The fence detection is shared with `parseFrontmatter` via
|
|
17
|
+
// `locateFrontmatter`; field extraction is done inline here because `extractSkillName`
|
|
18
|
+
// has semantics that differ from `parseFrontmatter`'s `parseScalar`:
|
|
19
|
+
// - An unclosed fence returns `null` (not `FRONTMATTER_PARSE_ERROR`) — skill
|
|
20
|
+
// discovery treats malformed files as "no name".
|
|
21
|
+
// - Trailing `# comment` is stripped from unquoted name values (YAML line
|
|
22
|
+
// comments), which `parseScalar` does not do.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract the `name` field from a `SKILL.md` file's YAML frontmatter.
|
|
26
|
+
*
|
|
27
|
+
* The parser tolerates unknown keys (consistent with OpenCode's `isSkillFrontmatter`
|
|
28
|
+
* duck-type check), extra whitespace, and both quoted and unquoted values. Returns
|
|
29
|
+
* `null` if the frontmatter block is absent, malformed, or lacks a `name` field.
|
|
30
|
+
*
|
|
31
|
+
* This is intentionally minimal — we only need the `name` string.
|
|
32
|
+
*/
|
|
33
|
+
export function extractSkillName(content: string): string | null {
|
|
34
|
+
const located = locateFrontmatter(content)
|
|
35
|
+
// Both "no fence" and "unclosed fence" map to null — skill discovery skips
|
|
36
|
+
// files that don't have a complete, parseable frontmatter block.
|
|
37
|
+
if (located === null || located === FRONTMATTER_PARSE_ERROR) return null
|
|
38
|
+
|
|
39
|
+
const { lines, closingIdx } = located
|
|
40
|
+
const frontmatter = lines.slice(1, closingIdx)
|
|
41
|
+
for (const line of frontmatter) {
|
|
42
|
+
// Match `name: <value>` only on non-indented lines (no leading whitespace).
|
|
43
|
+
// Indented `name:` lines are nested under block scalars (e.g. `description: |`)
|
|
44
|
+
// and must not be misread as the top-level skill name.
|
|
45
|
+
// Value may be bare, single-, or double-quoted.
|
|
46
|
+
const match = /^name\s*:\s*(.+)$/.exec(line)
|
|
47
|
+
if (!match) continue
|
|
48
|
+
const raw = match[1]!.trim()
|
|
49
|
+
// Strip surrounding quotes (YAML allows `"value"` or `'value'`).
|
|
50
|
+
// Quoted values are returned as-is after unquoting (the comment stripping
|
|
51
|
+
// below does not apply — a `#` inside quotes is part of the value).
|
|
52
|
+
if (
|
|
53
|
+
(raw.startsWith('"') && raw.endsWith('"')) ||
|
|
54
|
+
(raw.startsWith("'") && raw.endsWith("'"))
|
|
55
|
+
) {
|
|
56
|
+
return raw.slice(1, -1)
|
|
57
|
+
}
|
|
58
|
+
// For bare (unquoted) values, strip a trailing YAML line comment.
|
|
59
|
+
// YAML comment syntax: ` # ...` — whitespace before `#` is required to
|
|
60
|
+
// distinguish from a `#` that is part of an unquoted scalar value.
|
|
61
|
+
const commentIdx = raw.search(/\s+#/)
|
|
62
|
+
const value = commentIdx === -1 ? raw : raw.slice(0, commentIdx).trim()
|
|
63
|
+
return value || null
|
|
64
|
+
}
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Glob expansion ────────────────────────────────────────────────────────────
|
|
69
|
+
//
|
|
70
|
+
// Bun exposes `Bun.Glob` for glob matching, but this module must be runnable in
|
|
71
|
+
// plain Node tests and in non-Bun environments. We use a minimal recursive
|
|
72
|
+
// directory walker instead — it is sufficient for the patterns OpenCode uses,
|
|
73
|
+
// which are all of the form `<prefix>/**/SKILL.md`.
|
|
74
|
+
//
|
|
75
|
+
// The patterns from `opencode-builtins.ts`:
|
|
76
|
+
// - `skills/**/SKILL.md` — external (.claude/.agents) roots
|
|
77
|
+
// - `{skill,skills}/**/SKILL.md` — opencode config dirs
|
|
78
|
+
// - `**/SKILL.md` — cfg.skills.paths entries
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Expand a glob pattern that matches `SKILL.md` files under `root`.
|
|
82
|
+
*
|
|
83
|
+
* Handles the three patterns used by OpenCode's skill discovery:
|
|
84
|
+
* - `skills/**\/SKILL.md` — files under a `skills/` subdir
|
|
85
|
+
* - `skill,skills/**\/SKILL.md` — files under `skill/` or `skills/`
|
|
86
|
+
* - `**\/SKILL.md` — any SKILL.md recursively
|
|
87
|
+
*
|
|
88
|
+
* Missing or inaccessible roots return an empty array (callers skip absent dirs).
|
|
89
|
+
*/
|
|
90
|
+
async function globSkillMd(root: string, pattern: string): Promise<string[]> {
|
|
91
|
+
// Determine the set of immediate subdirs to descend into (or "." for **)
|
|
92
|
+
const prefixes = resolveGlobPrefixes(pattern)
|
|
93
|
+
const results: string[] = []
|
|
94
|
+
|
|
95
|
+
for (const prefix of prefixes) {
|
|
96
|
+
const searchRoot = prefix === "." ? root : path.join(root, prefix)
|
|
97
|
+
await collectSkillMd(searchRoot, results)
|
|
98
|
+
}
|
|
99
|
+
return results
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the literal directory prefix(es) from the patterns we use.
|
|
104
|
+
* Returns `["."]` for `**\/SKILL.md`, `["skills"]` for `skills\/**\/SKILL.md`, and
|
|
105
|
+
* `["skill", "skills"]` for the brace-expansion pattern.
|
|
106
|
+
*/
|
|
107
|
+
function resolveGlobPrefixes(pattern: string): string[] {
|
|
108
|
+
if (pattern === PATHS_SKILL_GLOB) return ["."]
|
|
109
|
+
if (pattern === EXTERNAL_SKILL_GLOB) return ["skills"]
|
|
110
|
+
if (pattern === OPENCODE_SKILL_GLOB) return ["skill", "skills"]
|
|
111
|
+
// Fallback: treat as `**/SKILL.md`
|
|
112
|
+
return ["."]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Recursively collect all `SKILL.md` files under `dir`, ignoring errors. */
|
|
116
|
+
async function collectSkillMd(dir: string, results: string[]): Promise<void> {
|
|
117
|
+
let entries: string[]
|
|
118
|
+
try {
|
|
119
|
+
entries = await fs.readdir(dir)
|
|
120
|
+
} catch {
|
|
121
|
+
return // missing or inaccessible dir — skip silently
|
|
122
|
+
}
|
|
123
|
+
for (const name of entries) {
|
|
124
|
+
const fullPath = path.join(dir, name)
|
|
125
|
+
let stat: Awaited<ReturnType<typeof fs.stat>>
|
|
126
|
+
try {
|
|
127
|
+
stat = await fs.stat(fullPath)
|
|
128
|
+
} catch {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (stat.isDirectory()) {
|
|
132
|
+
await collectSkillMd(fullPath, results)
|
|
133
|
+
} else if (stat.isFile() && name === "SKILL.md") {
|
|
134
|
+
results.push(fullPath)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Project-upward walk ────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Walk up from `start` toward the filesystem root (or `stop` if given), collecting
|
|
143
|
+
* directories whose basename matches one of `targets`.
|
|
144
|
+
*
|
|
145
|
+
* Intentional difference from `FSUtil.up` (`packages/core/src/fs-util.ts:141-155`):
|
|
146
|
+
* the bridge collects only *directories* (via `stat.isDirectory()`), whereas
|
|
147
|
+
* OpenCode's `up()` records any matching path regardless of type (file or dir).
|
|
148
|
+
* The skill-root targets (`.claude`, `.agents`, `.opencode`) are always directories,
|
|
149
|
+
* so this narrowing is safe and avoids spurious stat checks for non-dir matches.
|
|
150
|
+
*/
|
|
151
|
+
async function walkUp(targets: readonly string[], start: string, stop?: string): Promise<string[]> {
|
|
152
|
+
const result: string[] = []
|
|
153
|
+
let current = path.resolve(start)
|
|
154
|
+
const stopAt = stop ? path.resolve(stop) : undefined
|
|
155
|
+
|
|
156
|
+
while (true) {
|
|
157
|
+
for (const target of targets) {
|
|
158
|
+
const candidate = path.join(current, target)
|
|
159
|
+
try {
|
|
160
|
+
const stat = await fs.stat(candidate)
|
|
161
|
+
if (stat.isDirectory()) result.push(candidate)
|
|
162
|
+
} catch {
|
|
163
|
+
// not found or inaccessible — skip
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (stopAt !== undefined && current === stopAt) break
|
|
167
|
+
const parent = path.dirname(current)
|
|
168
|
+
if (parent === current) break // filesystem root
|
|
169
|
+
current = parent
|
|
170
|
+
}
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Options for {@link collectExistingSkillNames}.
|
|
178
|
+
*/
|
|
179
|
+
export interface SkillScanOptions {
|
|
180
|
+
/**
|
|
181
|
+
* The user's home directory (`$HOME`). Passed explicitly so the function is
|
|
182
|
+
* testable with temp-dir fixtures without relying on `os.homedir()`.
|
|
183
|
+
*/
|
|
184
|
+
home: string
|
|
185
|
+
/**
|
|
186
|
+
* The current project directory — OpenCode's "instance directory" (`input.directory`).
|
|
187
|
+
* Used as the starting point for project-upward scans.
|
|
188
|
+
*/
|
|
189
|
+
projectDir: string
|
|
190
|
+
/**
|
|
191
|
+
* Explicit skill paths already in `cfg.skills.paths`. Entries are expanded the
|
|
192
|
+
* same way OpenCode does (`skill/index.ts:212-213`): `~/` is replaced with `home`,
|
|
193
|
+
* and relative paths are resolved against `projectDir`. Absolute paths are used as-is.
|
|
194
|
+
*/
|
|
195
|
+
skillsPaths?: readonly string[]
|
|
196
|
+
/**
|
|
197
|
+
* Override for `$XDG_CONFIG_HOME`. When omitted, `process.env["XDG_CONFIG_HOME"]`
|
|
198
|
+
* is used, falling back to `<home>/.config`. Provide this in tests to keep them
|
|
199
|
+
* hermetic regardless of the host's real `XDG_CONFIG_HOME` value.
|
|
200
|
+
*/
|
|
201
|
+
xdgConfigHome?: string
|
|
202
|
+
/**
|
|
203
|
+
* Mirror of OpenCode's `OPENCODE_DISABLE_EXTERNAL_SKILLS` runtime flag.
|
|
204
|
+
*
|
|
205
|
+
* When `true`, skip ALL external skill roots: neither the global `~/.claude` /
|
|
206
|
+
* `~/.agents` dirs nor the project-upward `.claude` / `.agents` walk is scanned.
|
|
207
|
+
* This matches `discoverSkills`'s `disableExternalSkills` branch (`skill/index.ts`).
|
|
208
|
+
*
|
|
209
|
+
* Populate from `process.env["OPENCODE_DISABLE_EXTERNAL_SKILLS"] === "true"` in
|
|
210
|
+
* the hook so the bridge sees the same discovery set OpenCode does.
|
|
211
|
+
*/
|
|
212
|
+
disableExternalSkills?: boolean
|
|
213
|
+
/**
|
|
214
|
+
* Mirror of OpenCode's combined `OPENCODE_DISABLE_CLAUDE_CODE` /
|
|
215
|
+
* `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` runtime flag.
|
|
216
|
+
*
|
|
217
|
+
* When `true` (and `disableExternalSkills` is false), only `.claude` is removed
|
|
218
|
+
* from the external dirs list — `.agents` is still scanned. This matches
|
|
219
|
+
* `discoverSkills`'s `disableClaudeCodeSkills` branch (`skill/index.ts`).
|
|
220
|
+
*
|
|
221
|
+
* Populate from:
|
|
222
|
+
* ```
|
|
223
|
+
* process.env["OPENCODE_DISABLE_CLAUDE_CODE"] === "true" ||
|
|
224
|
+
* process.env["OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"] === "true"
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
disableClaudeCodeSkills?: boolean
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Collect the set of skill names that OpenCode will discover from all the same
|
|
232
|
+
* directories it would scan itself, so the bridge can detect collisions before
|
|
233
|
+
* injecting plugin skills.
|
|
234
|
+
*
|
|
235
|
+
* This reimplements OpenCode's `discoverSkills` glob (`skill/index.ts:173-233`)
|
|
236
|
+
* without calling any OpenCode service — doing so from the plugin config hook would
|
|
237
|
+
* force the lazy Skill cache to populate before our injected `cfg.skills.paths`,
|
|
238
|
+
* making those skills invisible (§6.3).
|
|
239
|
+
*
|
|
240
|
+
* Missing directories are silently skipped; read errors on individual `SKILL.md`
|
|
241
|
+
* files cause that file to be skipped. The built-in skill names are always included
|
|
242
|
+
* regardless of what files exist on disk.
|
|
243
|
+
*
|
|
244
|
+
* @param opts - Home directory, project directory, and explicit skill paths.
|
|
245
|
+
* @returns The set of all skill names OpenCode would already know about.
|
|
246
|
+
*/
|
|
247
|
+
export async function collectExistingSkillNames(opts: SkillScanOptions): Promise<Set<string>> {
|
|
248
|
+
const names = new Set<string>(BUILTIN_SKILL_NAMES)
|
|
249
|
+
|
|
250
|
+
// ── 1. External roots: ~/.claude, ~/.agents (global) ────────────────────
|
|
251
|
+
// + project-upward .claude, .agents dirs
|
|
252
|
+
// Pattern: `skills/**/SKILL.md`
|
|
253
|
+
// Source: skill/index.ts:185-203
|
|
254
|
+
//
|
|
255
|
+
// Guarded by OpenCode's runtime flags (env vars):
|
|
256
|
+
// `disableExternalSkills` → skip the entire block
|
|
257
|
+
// `disableClaudeCodeSkills`→ skip only .claude; .agents still scanned
|
|
258
|
+
|
|
259
|
+
if (!opts.disableExternalSkills) {
|
|
260
|
+
// Determine which external roots to scan, mirroring OpenCode's externalDirs logic.
|
|
261
|
+
const externalRoots: readonly string[] = opts.disableClaudeCodeSkills
|
|
262
|
+
? EXTERNAL_SKILL_ROOTS.filter((dir) => dir !== ".claude")
|
|
263
|
+
: EXTERNAL_SKILL_ROOTS
|
|
264
|
+
|
|
265
|
+
const externalGlobalRoots = externalRoots.map((dir) => path.join(opts.home, dir))
|
|
266
|
+
for (const root of externalGlobalRoots) {
|
|
267
|
+
await scanSkillsUnder(root, EXTERNAL_SKILL_GLOB, names)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const upwardExternalDirs = await walkUp(externalRoots, opts.projectDir)
|
|
271
|
+
for (const root of upwardExternalDirs) {
|
|
272
|
+
await scanSkillsUnder(root, EXTERNAL_SKILL_GLOB, names)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── 2. OpenCode config dirs ───────────────────────────────────────────────
|
|
277
|
+
// - Global XDG config dir: `~/.config/opencode` (or $XDG_CONFIG_HOME/opencode)
|
|
278
|
+
// - project-upward `.opencode` dirs
|
|
279
|
+
// - `~/.opencode` (home-based `.opencode`)
|
|
280
|
+
// Pattern: `{skill,skills}/**/SKILL.md`
|
|
281
|
+
// Source: skill/index.ts:205-207, config/paths.ts:23-41
|
|
282
|
+
|
|
283
|
+
const xdgConfigHome =
|
|
284
|
+
opts.xdgConfigHome ?? process.env["XDG_CONFIG_HOME"] ?? path.join(opts.home, ".config")
|
|
285
|
+
const globalOpencodeConfig = path.join(xdgConfigHome, "opencode")
|
|
286
|
+
await scanSkillsUnder(globalOpencodeConfig, OPENCODE_SKILL_GLOB, names)
|
|
287
|
+
|
|
288
|
+
// project-upward .opencode dirs
|
|
289
|
+
const upwardOpencodeConfigDirs = await walkUp([OPENCODE_CONFIG_DIR_NAME], opts.projectDir)
|
|
290
|
+
for (const root of upwardOpencodeConfigDirs) {
|
|
291
|
+
await scanSkillsUnder(root, OPENCODE_SKILL_GLOB, names)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ~/.opencode (from home-scoped up walk with stop=home, same as config/paths.ts:34-38)
|
|
295
|
+
const homeOpencodeDir = path.join(opts.home, OPENCODE_CONFIG_DIR_NAME)
|
|
296
|
+
// Only scan if it isn't already covered by the upward walk above
|
|
297
|
+
if (!upwardOpencodeConfigDirs.some((d) => path.resolve(d) === path.resolve(homeOpencodeDir))) {
|
|
298
|
+
await scanSkillsUnder(homeOpencodeDir, OPENCODE_SKILL_GLOB, names)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── 3. cfg.skills.paths entries ──────────────────────────────────────────
|
|
302
|
+
// Pattern: `**/SKILL.md`
|
|
303
|
+
// Source: skill/index.ts:210-220
|
|
304
|
+
// OpenCode expands `~/` (→ home) and resolves relative paths against the
|
|
305
|
+
// project dir before scanning (`skill/index.ts:212-213`). Mirror that here
|
|
306
|
+
// so collision detection sees the same paths OpenCode will scan.
|
|
307
|
+
for (const rawPath of opts.skillsPaths ?? []) {
|
|
308
|
+
const skillPath = expandSkillPath(rawPath, opts.home, opts.projectDir)
|
|
309
|
+
await scanSkillsUnder(skillPath, PATHS_SKILL_GLOB, names)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return names
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Scan `root` for `SKILL.md` files using `pattern`, extract each file's `name`
|
|
317
|
+
* frontmatter field, and add the names to `out`. Files whose frontmatter cannot
|
|
318
|
+
* be parsed are silently skipped (consistent with OpenCode's lenient parser).
|
|
319
|
+
*/
|
|
320
|
+
async function scanSkillsUnder(root: string, pattern: string, out: Set<string>): Promise<void> {
|
|
321
|
+
const files = await globSkillMd(root, pattern)
|
|
322
|
+
for (const file of files) {
|
|
323
|
+
let content: string
|
|
324
|
+
try {
|
|
325
|
+
content = await fs.readFile(file, "utf8")
|
|
326
|
+
} catch {
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
const name = extractSkillName(content)
|
|
330
|
+
if (name !== null) out.add(name)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Expand a `cfg.skills.paths` entry the same way OpenCode does
|
|
336
|
+
* (`skill/index.ts:212-213`):
|
|
337
|
+
* - `~/…` is expanded to `<home>/…`
|
|
338
|
+
* - Relative paths are resolved against `projectDir`
|
|
339
|
+
* - Absolute paths are returned as-is
|
|
340
|
+
*/
|
|
341
|
+
function expandSkillPath(rawPath: string, home: string, projectDir: string): string {
|
|
342
|
+
if (rawPath.startsWith("~/")) {
|
|
343
|
+
return path.join(home, rawPath.slice(2))
|
|
344
|
+
}
|
|
345
|
+
if (path.isAbsolute(rawPath)) {
|
|
346
|
+
return rawPath
|
|
347
|
+
}
|
|
348
|
+
return path.join(projectDir, rawPath)
|
|
349
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge's runtime configuration, parsed from the `opencode.json` plugin-tuple
|
|
3
|
+
* options object. Only the documented keys are honored; everything else is ignored
|
|
4
|
+
* (with a warning). See the design's §4.2 config schema.
|
|
5
|
+
*/
|
|
6
|
+
export interface BridgeConfig {
|
|
7
|
+
/** The only accepted mode: mirror exactly the Claude plugins Claude reports as enabled. */
|
|
8
|
+
mode: "mirror-claude"
|
|
9
|
+
/** Inject MCP servers from plugins (global on/off). Safe-by-default off. */
|
|
10
|
+
allowMcp: boolean
|
|
11
|
+
/** Inject LSP servers from plugins (global on/off). Safe-by-default off. */
|
|
12
|
+
allowLsp: boolean
|
|
13
|
+
/** Plugin ids (`name@marketplace`) to never inject. */
|
|
14
|
+
blockedPlugins: string[]
|
|
15
|
+
/** Promote soft warnings (parse failures, missing CLI) to hard errors. */
|
|
16
|
+
strict: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_BRIDGE_CONFIG: BridgeConfig = {
|
|
20
|
+
mode: "mirror-claude",
|
|
21
|
+
allowMcp: false,
|
|
22
|
+
allowLsp: false,
|
|
23
|
+
blockedPlugins: [],
|
|
24
|
+
strict: false,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The scope a Claude plugin is enabled under, as reported by `claude plugin list --json`. */
|
|
28
|
+
export type ClaudePluginScope = "user" | "project" | "local"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A single entry from `claude plugin list --json`. `installPath` is the fully-resolved
|
|
32
|
+
* on-disk plugin directory — no marketplace/git/npm resolution is needed in mirror mode.
|
|
33
|
+
*/
|
|
34
|
+
export interface ClaudePlugin {
|
|
35
|
+
/** `name@marketplace`. */
|
|
36
|
+
id: string
|
|
37
|
+
version: string
|
|
38
|
+
scope: ClaudePluginScope
|
|
39
|
+
enabled: boolean
|
|
40
|
+
/** Resolved on-disk plugin directory. */
|
|
41
|
+
installPath: string
|
|
42
|
+
installedAt?: string
|
|
43
|
+
lastUpdated?: string
|
|
44
|
+
/** The project a `project`/`local`-scoped entry is bound to; absent/null for `user` scope. */
|
|
45
|
+
projectPath?: string | null
|
|
46
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Logger } from "./logger.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Supported OpenCode version range. The injection approach relies on OpenCode-*internal*
|
|
5
|
+
* behavior (a shared mutable config object + lazy-after-`plugin.init()` service init) that
|
|
6
|
+
* is not a documented public contract, so the bridge pins a conservative same-minor window
|
|
7
|
+
* and warns outside it. Widen only when the e2e canary passes against a new version (§9).
|
|
8
|
+
*/
|
|
9
|
+
export const SUPPORTED_OPENCODE_RANGE = ">=1.15.0 <1.16.0"
|
|
10
|
+
|
|
11
|
+
/** The OpenCode version the design and range were verified against (§9). */
|
|
12
|
+
export const VERIFIED_OPENCODE_VERSION = "1.15.10"
|
|
13
|
+
|
|
14
|
+
interface Semver {
|
|
15
|
+
major: number
|
|
16
|
+
minor: number
|
|
17
|
+
patch: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a `major.minor.patch` string, tolerating a leading `v` and ignoring any
|
|
22
|
+
* prerelease/build suffix (`-rc.1`, `+build`). Returns `null` if the core triple is
|
|
23
|
+
* not three integers.
|
|
24
|
+
*/
|
|
25
|
+
export function parseSemver(version: string): Semver | null {
|
|
26
|
+
// Anchored end so trailing garbage ("1.2.3abc", "1.2.3.4") is rejected rather than
|
|
27
|
+
// silently truncated to a bogus triple; only a `-prerelease` / `+build` suffix is allowed.
|
|
28
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim())
|
|
29
|
+
if (!match) return null
|
|
30
|
+
return {
|
|
31
|
+
major: Number(match[1]),
|
|
32
|
+
minor: Number(match[2]),
|
|
33
|
+
patch: Number(match[3]),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compare(a: Semver, b: Semver): number {
|
|
38
|
+
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const COMPARATORS: Record<string, (cmp: number) => boolean> = {
|
|
42
|
+
">=": (c) => c >= 0,
|
|
43
|
+
"<=": (c) => c <= 0,
|
|
44
|
+
">": (c) => c > 0,
|
|
45
|
+
"<": (c) => c < 0,
|
|
46
|
+
"=": (c) => c === 0,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Test `version` against a space-separated AND range of simple comparator clauses
|
|
51
|
+
* (e.g. `">=1.15.0 <1.16.0"`). Returns `false` if `version` is unparseable or any
|
|
52
|
+
* clause is malformed — callers treat that as "outside the supported range".
|
|
53
|
+
*/
|
|
54
|
+
export function satisfiesRange(version: string, range: string): boolean {
|
|
55
|
+
const parsed = parseSemver(version)
|
|
56
|
+
if (!parsed) return false
|
|
57
|
+
|
|
58
|
+
const clauses = range.trim().split(/\s+/).filter(Boolean)
|
|
59
|
+
for (const clause of clauses) {
|
|
60
|
+
const match = /^(>=|<=|>|<|=)?\s*(.+)$/.exec(clause)
|
|
61
|
+
if (!match) return false
|
|
62
|
+
const op = match[2] === undefined ? null : (match[1] ?? "=")
|
|
63
|
+
const bound = parseSemver(match[2]!)
|
|
64
|
+
if (op === null || !bound) return false
|
|
65
|
+
if (!COMPARATORS[op]!(compare(parsed, bound))) return false
|
|
66
|
+
}
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read the running OpenCode version from the live HTTP server's health endpoint.
|
|
72
|
+
*
|
|
73
|
+
* This is the only mechanism available to an external plugin: the v1 `input.client`
|
|
74
|
+
* exposes no version method, there is no `OPENCODE_VERSION` env var at runtime, and the
|
|
75
|
+
* build-time define global is not visible outside OpenCode's own bundle. The endpoint is
|
|
76
|
+
* mounted at the server root (`GET /global/health` → `{ healthy, version }`) and the HTTP
|
|
77
|
+
* server is already running before `plugin.init()` fires, so calling it from the config
|
|
78
|
+
* hook is safe and touches none of the lazy component services.
|
|
79
|
+
*
|
|
80
|
+
* Returns `null` on any failure (network, parse, missing field) — the caller degrades to
|
|
81
|
+
* "version unknown" rather than blocking injection.
|
|
82
|
+
*/
|
|
83
|
+
export async function fetchOpencodeVersion(serverUrl: URL): Promise<string | null> {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(new URL("/global/health", serverUrl))
|
|
86
|
+
if (!res.ok) return null
|
|
87
|
+
const body = (await res.json()) as { version?: unknown }
|
|
88
|
+
return typeof body.version === "string" ? body.version : null
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compare the running version against {@link SUPPORTED_OPENCODE_RANGE} and emit the §9
|
|
96
|
+
* advisory warning when it falls outside. This warning is **always soft** — even under
|
|
97
|
+
* `strict`, the bridge still attempts injection (the e2e suite is the real canary). A
|
|
98
|
+
* `null` version (health unreachable) is reported as an inability to verify, not a failure.
|
|
99
|
+
*/
|
|
100
|
+
export function checkVersion(version: string | null, logger: Logger): void {
|
|
101
|
+
if (version === null) {
|
|
102
|
+
logger.warn(
|
|
103
|
+
`could not determine the running OpenCode version; proceeding (supported range ${SUPPORTED_OPENCODE_RANGE})`,
|
|
104
|
+
{ fatalInStrict: false },
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (!satisfiesRange(version, SUPPORTED_OPENCODE_RANGE)) {
|
|
109
|
+
logger.warn(
|
|
110
|
+
`untested OpenCode version ${version} (supported range ${SUPPORTED_OPENCODE_RANGE}) — bridge may misbehave; attempting injection anyway`,
|
|
111
|
+
{ fatalInStrict: false },
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|