@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,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
+ }