@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleksander Stelmaczonek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# opencode-claude-bridge
|
|
2
|
+
|
|
3
|
+
An [OpenCode](https://opencode.ai) plugin that bridges your **enabled Claude Code plugins** —
|
|
4
|
+
the bundles managed by `claude plugin install` and stored under `~/.claude/plugins/cache/…` — into
|
|
5
|
+
OpenCode at runtime. Their **commands, agents, skills** (and, opt-in, **MCP** and **LSP** servers)
|
|
6
|
+
become available inside OpenCode, namespaced so they never shadow your existing items.
|
|
7
|
+
|
|
8
|
+
It is a single plugin: no wrapper binary, no generated files, no lockfile. You run plain
|
|
9
|
+
`opencode`; the plugin reads Claude's enabled-plugin state via `claude plugin list --json` and
|
|
10
|
+
injects the components live on each launch.
|
|
11
|
+
|
|
12
|
+
> **Status.** Early development. Commands, agents, skills, MCP servers, and LSP servers from
|
|
13
|
+
> enabled Claude plugins are all injected into OpenCode (see below). MCP and LSP are opt-in and
|
|
14
|
+
> off by default.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- **OpenCode** within the supported range below.
|
|
19
|
+
- The **`claude` CLI** on your `PATH` at OpenCode runtime — the bridge's entire purpose is reading
|
|
20
|
+
Claude's plugin state. If it is missing, the bridge logs a warning and injects nothing (OpenCode
|
|
21
|
+
still starts normally).
|
|
22
|
+
|
|
23
|
+
### Supported OpenCode version
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
>=1.15.0 <1.16.0
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Verified against **OpenCode 1.15.10**. The bridge relies on OpenCode-internal behavior that is not
|
|
30
|
+
a documented public contract, so it pins a conservative same-minor window. At startup it reads the
|
|
31
|
+
running OpenCode version and logs a one-line warning if you are outside this range — it still
|
|
32
|
+
attempts to work, but treat that as untested. The range is widened only after the end-to-end suite
|
|
33
|
+
passes against a new version.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
Add the plugin to your **global** `~/.config/opencode/opencode.json` so it applies across all
|
|
38
|
+
projects:
|
|
39
|
+
|
|
40
|
+
```jsonc
|
|
41
|
+
{
|
|
42
|
+
"plugin": [
|
|
43
|
+
[
|
|
44
|
+
"@koriit/opencode-claude-bridge",
|
|
45
|
+
{
|
|
46
|
+
"allowMcp": false,
|
|
47
|
+
"allowLsp": false,
|
|
48
|
+
"blockedPlugins": []
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The bare-string form works too and uses all defaults:
|
|
56
|
+
|
|
57
|
+
```jsonc
|
|
58
|
+
{
|
|
59
|
+
"plugin": ["@koriit/opencode-claude-bridge"]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**How it works.** OpenCode installs plugins via Arborist with `ignoreScripts: true` — no build step
|
|
64
|
+
runs on install. The package entry points at `src/index.ts` intentionally: OpenCode runs on Bun,
|
|
65
|
+
which imports TypeScript directly. Zero runtime dependencies; all `@opencode-ai/plugin` imports are
|
|
66
|
+
`import type` (erased at runtime).
|
|
67
|
+
|
|
68
|
+
### Releasing (maintainer)
|
|
69
|
+
|
|
70
|
+
1. Bump `version` in `package.json`, commit.
|
|
71
|
+
2. Create a GitHub Release with tag `v<version>` (e.g. `v0.1.1`).
|
|
72
|
+
3. The [publish workflow](.github/workflows/publish.yml) runs the test gates and publishes to npm
|
|
73
|
+
automatically (requires the `NPM_TOKEN` secret to be configured in the repo — see the workflow
|
|
74
|
+
file header for setup instructions).
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
All keys are optional; the table shows their defaults.
|
|
79
|
+
|
|
80
|
+
| Key | Type | Default | Meaning |
|
|
81
|
+
| ---------------- | ----------------- | --------------- | -------------------------------------------------------------- |
|
|
82
|
+
| `mode` | `"mirror-claude"` | `mirror-claude` | The only accepted mode (mirror exactly Claude's enabled set). |
|
|
83
|
+
| `allowMcp` | boolean | `false` | Inject MCP servers from plugins (global on/off). |
|
|
84
|
+
| `allowLsp` | boolean | `false` | Inject LSP servers from plugins (global on/off). |
|
|
85
|
+
| `blockedPlugins` | `string[]` | `[]` | Plugin ids (`name@marketplace`) to never inject. |
|
|
86
|
+
| `strict` | boolean | `false` | Promote warnings (parse failures, missing CLI) to hard errors. |
|
|
87
|
+
|
|
88
|
+
Unknown keys and ill-typed values are ignored with a warning.
|
|
89
|
+
|
|
90
|
+
### What gets bridged (`mirror-claude`)
|
|
91
|
+
|
|
92
|
+
A Claude plugin is bridged when `claude plugin list --json` reports it as **enabled** and it is in
|
|
93
|
+
scope for the current project:
|
|
94
|
+
|
|
95
|
+
- `user`-scoped plugins always apply (they are global).
|
|
96
|
+
- `project`/`local`-scoped plugins apply only when their project matches your current directory.
|
|
97
|
+
- ids listed in `blockedPlugins` are never bridged.
|
|
98
|
+
|
|
99
|
+
### What is injected
|
|
100
|
+
|
|
101
|
+
| Component | Status | Notes |
|
|
102
|
+
| --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
103
|
+
| Commands | Injected | `commands/**/*.md` → `cfg.command`; `$ARGUMENTS`/`$1..n` pass through |
|
|
104
|
+
| Agents | Injected | `agents/*.md` → `cfg.agent`; `prompt` field (not `system`); `mode` defaults to `subagent` (also accepts `primary`/`all`); `temperature`, `top_p`, `steps`, `hidden`, `color`, `variant` passed through |
|
|
105
|
+
| Skills | Injected | `skills/<name>/SKILL.md` dirs → `cfg.skills.paths`; zero files copied in the common case; collision-renamed copies go to `~/.cache/opencode-claude-bridge/skills/` (see below) |
|
|
106
|
+
| MCP | Injected | Opt-in (`allowMcp: true`). Source: `<installPath>/.mcp.json`. Claude `type:"http"` → OpenCode `type:"remote"`; stdio/command → `type:"local"`. Processes spawned on connection. |
|
|
107
|
+
| LSP | Injected | Opt-in (`allowLsp: true`). Source: `<installPath>/.lsp.json`. `cfg.lsp === false` is respected. Processes spawned per file-type activation. |
|
|
108
|
+
|
|
109
|
+
### Skills: no-copy in the common case, bridge cache on collision
|
|
110
|
+
|
|
111
|
+
Each skill in a plugin's `skills/<name>/` directory is discovered by reading its `SKILL.md`
|
|
112
|
+
frontmatter `name`. In the common case — no name collision — the plugin's own skill directory is
|
|
113
|
+
pushed directly onto `cfg.skills.paths`: **zero files are copied**.
|
|
114
|
+
|
|
115
|
+
When a collision is detected (the bare name is already taken by a native OpenCode skill, a built-in,
|
|
116
|
+
or an earlier-processed plugin), the entire skill directory is copied to the bridge cache at:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
~/.cache/opencode-claude-bridge/skills/<marketplace>/<plugin>/<version>/<allocatedName>/
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
where `<marketplace>` and `<plugin>` are the two halves of the plugin id (e.g. `acme` and
|
|
123
|
+
`my-plugin` from `my-plugin@acme`). The copy's `SKILL.md` frontmatter `name` is patched to
|
|
124
|
+
the prefixed name; all other files (assets, sub-directories) are preserved so relative references
|
|
125
|
+
within the skill continue to work. The `.git` directory and other dot-directories are excluded
|
|
126
|
+
from copies.
|
|
127
|
+
|
|
128
|
+
Copies are keyed by `<marketplace>/<plugin>/<version>/<allocatedName>` and regenerated when the
|
|
129
|
+
source is newer. When the plugin version changes, the old version's cache directories are pruned
|
|
130
|
+
automatically (version GC). The bridge cache is distinct from OpenCode's own
|
|
131
|
+
`~/.cache/opencode/skills`.
|
|
132
|
+
|
|
133
|
+
To override the cache location (e.g. in CI or test environments), set the
|
|
134
|
+
`OPENCODE_CLAUDE_BRIDGE_CACHE_ROOT` environment variable before starting OpenCode.
|
|
135
|
+
|
|
136
|
+
> **URL-sourced skills:** if your `opencode.json` lists entries in `cfg.skills.urls`, the bridge
|
|
137
|
+
> cannot detect collision against them at hook time (fetching URL skills would force the lazy Skill
|
|
138
|
+
> service to load before our injected paths). A warning is logged when URLs are present.
|
|
139
|
+
|
|
140
|
+
### MCP servers (opt-in)
|
|
141
|
+
|
|
142
|
+
When `allowMcp: true`, the bridge reads each enabled plugin's top-level `.mcp.json` and injects
|
|
143
|
+
the declared servers into OpenCode's flat `cfg.mcp` record. The mapping:
|
|
144
|
+
|
|
145
|
+
- Claude `type:"http"` → OpenCode `{ type:"remote", url, headers?, oauth? }`
|
|
146
|
+
- Claude stdio/command servers → OpenCode `{ type:"local", command:[cmd, ...args], environment? }`
|
|
147
|
+
- `${CLAUDE_PLUGIN_ROOT}` is resolved to the plugin's `installPath` in all string fields.
|
|
148
|
+
- OAuth: the `clientId`, `callbackPort`, etc. field names are identical between Claude and OpenCode.
|
|
149
|
+
|
|
150
|
+
**Name:** `<plugin>-<server>` (e.g. a plugin `slack@official` with server `slack` becomes
|
|
151
|
+
`slack-slack`). The same no-shadowing + collision-rename rules apply.
|
|
152
|
+
|
|
153
|
+
Because MCP servers spawn external processes (and can initiate network connections), they are
|
|
154
|
+
gated behind an explicit `allowMcp: true` toggle — **off by default.**
|
|
155
|
+
|
|
156
|
+
### LSP servers (opt-in)
|
|
157
|
+
|
|
158
|
+
When `allowLsp: true`, the bridge reads each enabled plugin's top-level `.lsp.json` and injects
|
|
159
|
+
the declared servers into `cfg.lsp`. The mapping:
|
|
160
|
+
|
|
161
|
+
- Claude `command` (string) + `args` (array) → OpenCode `command` (string array)
|
|
162
|
+
- Claude `extensionToLanguage` keys (e.g. `{ ".rs": "rust" }`) → OpenCode `extensions` array
|
|
163
|
+
- `env`, `initializationOptions` (falling back to `settings`) → `env`, `initialization`
|
|
164
|
+
(only one is used; `initializationOptions` takes precedence — they do not merge)
|
|
165
|
+
- `${CLAUDE_PLUGIN_ROOT}` is resolved in command, args, and env values.
|
|
166
|
+
- Servers with no `.`-prefixed keys in `extensionToLanguage`, or with `transport: "socket"`,
|
|
167
|
+
are skipped with a warning (OpenCode requires `extensions` for custom LSP servers and has
|
|
168
|
+
no socket transport support).
|
|
169
|
+
|
|
170
|
+
**`cfg.lsp === false` is respected.** If the user explicitly set `lsp: false` in their
|
|
171
|
+
`opencode.json`, the bridge injects no LSP servers. This only skips LSP — commands, agents,
|
|
172
|
+
skills, and MCP still inject normally.
|
|
173
|
+
|
|
174
|
+
**Name:** `<plugin>-<server>` with the same collision-rename ladder.
|
|
175
|
+
|
|
176
|
+
Because LSP servers spawn external processes, they are gated behind `allowLsp: true` — **off by
|
|
177
|
+
default.**
|
|
178
|
+
|
|
179
|
+
> **LSP source note:** the `.lsp.json` convention is derived from Claude Code's plugin loader
|
|
180
|
+
> source. No real installed Claude LSP plugin with a `.lsp.json` was available to validate against
|
|
181
|
+
> at the time of implementation; validate against a live LSP plugin if you enable this feature.
|
|
182
|
+
|
|
183
|
+
### No-shadowing & naming
|
|
184
|
+
|
|
185
|
+
Injected items are namespaced so they **never shadow** your existing OpenCode commands, agents, or
|
|
186
|
+
skills (including OpenCode's built-ins). When a name collision is detected the **bridge's item** is
|
|
187
|
+
renamed — the native/existing item is never touched. The rename ladder:
|
|
188
|
+
|
|
189
|
+
1. `<plugin>-<name>` (e.g. `kio-development-audit`)
|
|
190
|
+
2. `<marketplace>-<plugin>-<name>` if still colliding
|
|
191
|
+
3. `<marketplace>-<plugin>-<name>-<8hex>` deterministic hash tiebreak
|
|
192
|
+
|
|
193
|
+
Processing order is sorted by plugin id, so the first claimant of a bare name wins
|
|
194
|
+
deterministically across runs.
|
|
195
|
+
|
|
196
|
+
Every injected item's description is suffixed with `[plugin-id]` for traceability.
|
|
197
|
+
|
|
198
|
+
### Security defaults
|
|
199
|
+
|
|
200
|
+
Commands, agents, and skills are text prompts (lower risk) and are always bridged — but always
|
|
201
|
+
namespaced so they can never shadow your own items. MCP and LSP servers **spawn processes** and
|
|
202
|
+
can initiate network connections, so they are gated behind the explicit `allowMcp` / `allowLsp`
|
|
203
|
+
toggles, both **off by default**.
|
|
204
|
+
|
|
205
|
+
- `blockedPlugins` hard-excludes plugin ids from all injection (commands, agents, skills, MCP, LSP).
|
|
206
|
+
- The `allowMcp`/`allowLsp` toggles are all-or-nothing per type — there is no per-plugin trust level.
|
|
207
|
+
- Disabled plugins (those reported as `enabled: false` by `claude plugin list --json`) are always skipped.
|
|
208
|
+
|
|
209
|
+
## Development
|
|
210
|
+
|
|
211
|
+
This is a Bun + TypeScript package.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
bun install # install dev dependencies
|
|
215
|
+
bun run typecheck # type-check src + tests
|
|
216
|
+
bun run build # emit dist/
|
|
217
|
+
bun run test # unit tests (sets OCB_TMPDIR=.tmp)
|
|
218
|
+
bun run test:e2e # end-to-end tests (sets OCB_TMPDIR=.tmp; launches real opencode)
|
|
219
|
+
bun run test:all # unit + e2e
|
|
220
|
+
bun run test:coverage # unit tests with line/function coverage report
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
> **Note on test commands:** always use `bun run test` (the npm script) rather than `bun test test/`
|
|
224
|
+
> directly. The scripts set `OCB_TMPDIR=.tmp` to keep test scratch off a potentially small system
|
|
225
|
+
> `/tmp`.
|
|
226
|
+
|
|
227
|
+
### Bridge diagnostics
|
|
228
|
+
|
|
229
|
+
Bridge log lines are prefixed `[opencode-claude-bridge]` and are written to the
|
|
230
|
+
`opencode` process's **stderr**. OpenCode does not rebind `console.*` and does not
|
|
231
|
+
capture plugin output into its own log file — the bridge's messages only reach
|
|
232
|
+
OpenCode's log file if they are written through OpenCode's own `Log.*` API, which
|
|
233
|
+
the bridge does not use.
|
|
234
|
+
|
|
235
|
+
**In-TUI nudge.** If the bridge emitted any warnings during startup, a single
|
|
236
|
+
`warning` toast appears on your first message in the TUI:
|
|
237
|
+
|
|
238
|
+
> opencode-claude-bridge encountered issues — run with --print-logs for details
|
|
239
|
+
|
|
240
|
+
The toast is deferred to your first chat interaction (rather than shown at startup)
|
|
241
|
+
because the TUI's event subscription is not yet guaranteed at the moment the config
|
|
242
|
+
hook runs (the hook fires on the first instance request, concurrent with the TUI
|
|
243
|
+
subscribing to the server's event stream). The toast fires at most once per session.
|
|
244
|
+
|
|
245
|
+
**Full diagnostic detail.** To see every `[opencode-claude-bridge]` log line,
|
|
246
|
+
run `opencode` (or `opencode serve`) with `--print-logs`:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
opencode --print-logs
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
In non-TUI mode (`opencode serve`) or when running without `--print-logs`, all
|
|
253
|
+
bridge output goes to stderr only — the toast nudge is not shown in non-TUI mode.
|
|
254
|
+
If something appears to be missing or misbehaving, rerun with `--print-logs` to
|
|
255
|
+
surface the full set of skip/warn messages.
|
|
256
|
+
|
|
257
|
+
The end-to-end suite launches a real `opencode serve` with the plugin loaded and a fake `claude`
|
|
258
|
+
CLI on `PATH`, then asserts behavior against the live HTTP API. It also acts as the
|
|
259
|
+
version-compatibility canary: run it after every OpenCode upgrade before widening the supported
|
|
260
|
+
range.
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@koriit/opencode-claude-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An OpenCode plugin that bridges enabled Claude Code plugins (commands, agents, skills, MCP, LSP) into OpenCode at runtime, namespaced so they never shadow your existing items.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/Koriit/opencode-claude-bridge.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/Koriit/opencode-claude-bridge#readme",
|
|
21
|
+
"bugs": "https://github.com/Koriit/opencode-claude-bridge/issues",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"provenance": true
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"clean": "rm -rf dist .tmp",
|
|
28
|
+
"build": "tsc -p tsconfig.build.json",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "OCB_TMPDIR=.tmp bun test test/",
|
|
31
|
+
"test:e2e": "OCB_TMPDIR=.tmp bun test e2e/",
|
|
32
|
+
"test:all": "OCB_TMPDIR=.tmp bun test",
|
|
33
|
+
"test:coverage": "bun run scripts/coverage-gate.ts"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"opencode",
|
|
37
|
+
"claude",
|
|
38
|
+
"claude-code",
|
|
39
|
+
"plugin",
|
|
40
|
+
"bridge"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"bun": ">=1.2.0"
|
|
44
|
+
},
|
|
45
|
+
"opencode": {
|
|
46
|
+
"supportedRange": ">=1.15.0 <1.16.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@opencode-ai/plugin": "1.15.13",
|
|
50
|
+
"@opencode-ai/sdk": "1.15.13",
|
|
51
|
+
"@types/bun": "latest",
|
|
52
|
+
"typescript": "^5.7.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DEFAULT_BRIDGE_CONFIG, type BridgeConfig } from "./types.js"
|
|
2
|
+
|
|
3
|
+
export interface ParsedBridgeConfig {
|
|
4
|
+
config: BridgeConfig
|
|
5
|
+
/**
|
|
6
|
+
* Validation warnings collected while parsing. These are strict-promotable: the
|
|
7
|
+
* caller replays them through the logger (which throws under `strict`). Parsing
|
|
8
|
+
* itself is pure and never throws, so `strict` can be resolved first and the
|
|
9
|
+
* logger built from it.
|
|
10
|
+
*/
|
|
11
|
+
warnings: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DOCUMENTED_KEYS = new Set(["mode", "allowMcp", "allowLsp", "blockedPlugins", "strict"])
|
|
15
|
+
|
|
16
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse the `opencode.json` plugin-tuple options into a {@link BridgeConfig}, honoring
|
|
22
|
+
* only the documented keys (§4.2). Unknown keys and ill-typed values are dropped to
|
|
23
|
+
* their defaults and reported as warnings. Pure and total — never throws.
|
|
24
|
+
*/
|
|
25
|
+
export function parseBridgeConfig(options: unknown): ParsedBridgeConfig {
|
|
26
|
+
const config: BridgeConfig = {
|
|
27
|
+
...DEFAULT_BRIDGE_CONFIG,
|
|
28
|
+
blockedPlugins: [...DEFAULT_BRIDGE_CONFIG.blockedPlugins],
|
|
29
|
+
}
|
|
30
|
+
const warnings: string[] = []
|
|
31
|
+
|
|
32
|
+
if (options === undefined || options === null) {
|
|
33
|
+
return { config, warnings }
|
|
34
|
+
}
|
|
35
|
+
if (!isPlainObject(options)) {
|
|
36
|
+
warnings.push(
|
|
37
|
+
`ignoring plugin options: expected an object, got ${Array.isArray(options) ? "array" : typeof options}`,
|
|
38
|
+
)
|
|
39
|
+
return { config, warnings }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if ("mode" in options) {
|
|
43
|
+
if (options["mode"] !== "mirror-claude") {
|
|
44
|
+
warnings.push(
|
|
45
|
+
`unsupported mode ${JSON.stringify(options["mode"])}; only "mirror-claude" is supported, using it`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
// mode is effectively hardcoded; nothing to assign beyond the default.
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const key of ["allowMcp", "allowLsp", "strict"] as const) {
|
|
52
|
+
if (key in options) {
|
|
53
|
+
const value = options[key]
|
|
54
|
+
if (typeof value === "boolean") {
|
|
55
|
+
config[key] = value
|
|
56
|
+
} else {
|
|
57
|
+
warnings.push(`ignoring "${key}": expected boolean, got ${typeof value}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if ("blockedPlugins" in options) {
|
|
63
|
+
const value = options["blockedPlugins"]
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
const strings = value.filter((v): v is string => typeof v === "string")
|
|
66
|
+
if (strings.length !== value.length) {
|
|
67
|
+
warnings.push(`ignoring non-string entries in "blockedPlugins"`)
|
|
68
|
+
}
|
|
69
|
+
config.blockedPlugins = strings
|
|
70
|
+
} else {
|
|
71
|
+
warnings.push(`ignoring "blockedPlugins": expected string[], got ${typeof value}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const key of Object.keys(options)) {
|
|
76
|
+
if (!DOCUMENTED_KEYS.has(key)) {
|
|
77
|
+
warnings.push(`ignoring unknown option "${key}"`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { config, warnings }
|
|
82
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal zero-dependency frontmatter parser for Claude command/agent/skill files.
|
|
3
|
+
*
|
|
4
|
+
* Claude frontmatter is a flat key-value YAML block. The fields we care about are all
|
|
5
|
+
* scalar strings, booleans, or numbers. We parse only those and silently ignore anything
|
|
6
|
+
* that requires full YAML parsing (nested maps, sequences, multi-line block scalars, etc.),
|
|
7
|
+
* consistent with the lenient approach used in `skill-scan.ts` for skill name extraction.
|
|
8
|
+
*
|
|
9
|
+
* This deliberately avoids adding a `gray-matter` or any other npm dependency.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const FRONTMATTER_FENCE = "---"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A parsed frontmatter block: the extracted flat scalar values and the markdown body
|
|
16
|
+
* (everything after the closing `---` fence, trimmed).
|
|
17
|
+
*
|
|
18
|
+
* Fields that were present but not parseable as flat scalars are omitted (not reported
|
|
19
|
+
* as errors — consistent with the lenient parse posture).
|
|
20
|
+
*/
|
|
21
|
+
export interface ParsedFrontmatter {
|
|
22
|
+
data: Record<string, string | boolean | number>
|
|
23
|
+
body: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sentinel returned when the file has an opening `---` fence but no closing one.
|
|
28
|
+
* Callers should skip the file and warn rather than treating the broken YAML as
|
|
29
|
+
* a no-frontmatter plain-markdown file (§10).
|
|
30
|
+
*/
|
|
31
|
+
export const FRONTMATTER_PARSE_ERROR: unique symbol = Symbol("FRONTMATTER_PARSE_ERROR")
|
|
32
|
+
export type FrontmatterParseError = typeof FRONTMATTER_PARSE_ERROR
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Split content into lines and locate the frontmatter fence boundaries.
|
|
36
|
+
*
|
|
37
|
+
* Returns:
|
|
38
|
+
* - `null` if the file does not begin with a `---` fence (no frontmatter).
|
|
39
|
+
* - `FRONTMATTER_PARSE_ERROR` if an opening fence is found but never closed.
|
|
40
|
+
* - `{ lines, closingIdx }` on success — `lines[1..closingIdx-1]` are the
|
|
41
|
+
* frontmatter key/value lines; `lines[closingIdx+1..]` is the body.
|
|
42
|
+
*
|
|
43
|
+
* This is the shared fence-detection core used by both `parseFrontmatter`
|
|
44
|
+
* (in this module) and `extractSkillName` (in `skill-scan.ts`). Both callers
|
|
45
|
+
* need fence detection but have different field-extraction semantics, so the
|
|
46
|
+
* extraction itself is left to each call site.
|
|
47
|
+
*/
|
|
48
|
+
export function locateFrontmatter(
|
|
49
|
+
content: string,
|
|
50
|
+
): { lines: string[]; closingIdx: number } | null | FrontmatterParseError {
|
|
51
|
+
const lines = content.split(/\r?\n/)
|
|
52
|
+
|
|
53
|
+
if (lines[0]?.trim() !== FRONTMATTER_FENCE) return null
|
|
54
|
+
|
|
55
|
+
let closingIdx = -1
|
|
56
|
+
for (let i = 1; i < lines.length; i++) {
|
|
57
|
+
if (lines[i]?.trim() === FRONTMATTER_FENCE) {
|
|
58
|
+
closingIdx = i
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (closingIdx === -1) return FRONTMATTER_PARSE_ERROR
|
|
63
|
+
|
|
64
|
+
return { lines, closingIdx }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse a markdown file's YAML frontmatter and return the flat scalar fields plus the body.
|
|
69
|
+
*
|
|
70
|
+
* Handles:
|
|
71
|
+
* - Bare values: `key: value`
|
|
72
|
+
* - Single- and double-quoted strings: `key: "value"` / `key: 'value'`
|
|
73
|
+
* - Booleans: `true` / `false` (case-insensitive)
|
|
74
|
+
* - Numbers: integer and floating-point decimal strings
|
|
75
|
+
*
|
|
76
|
+
* Ignores:
|
|
77
|
+
* - Lines that start with whitespace (nested YAML — inside a block scalar or map)
|
|
78
|
+
* - Lines whose value starts with `{`, `[`, or `|` (maps, sequences, block scalars)
|
|
79
|
+
* - YAML comments (`#`)
|
|
80
|
+
* - Anything that looks like a multi-line scalar continuation
|
|
81
|
+
*
|
|
82
|
+
* Returns:
|
|
83
|
+
* - `null` if the file does not begin with a `---` fence (no frontmatter — treat
|
|
84
|
+
* the whole file as the body).
|
|
85
|
+
* - `FRONTMATTER_PARSE_ERROR` if an opening fence is found but never closed —
|
|
86
|
+
* the caller must skip and warn rather than injecting broken YAML as body text (§10).
|
|
87
|
+
* - `ParsedFrontmatter` on success.
|
|
88
|
+
*/
|
|
89
|
+
export function parseFrontmatter(
|
|
90
|
+
content: string,
|
|
91
|
+
): ParsedFrontmatter | null | FrontmatterParseError {
|
|
92
|
+
const located = locateFrontmatter(content)
|
|
93
|
+
if (located === null || located === FRONTMATTER_PARSE_ERROR) return located
|
|
94
|
+
|
|
95
|
+
const { lines, closingIdx } = located
|
|
96
|
+
const data: Record<string, string | boolean | number> = {}
|
|
97
|
+
const fmLines = lines.slice(1, closingIdx)
|
|
98
|
+
|
|
99
|
+
for (const line of fmLines) {
|
|
100
|
+
// Skip blank lines, indented lines (nested values), and YAML comments.
|
|
101
|
+
if (!line || /^\s/.test(line) || line.trimStart().startsWith("#")) continue
|
|
102
|
+
|
|
103
|
+
// Match `key: value` — key must start at column 0.
|
|
104
|
+
const colonIdx = line.indexOf(":")
|
|
105
|
+
if (colonIdx < 1) continue
|
|
106
|
+
|
|
107
|
+
const key = line.slice(0, colonIdx).trim()
|
|
108
|
+
if (!key) continue
|
|
109
|
+
|
|
110
|
+
const rawValue = line.slice(colonIdx + 1).trim()
|
|
111
|
+
|
|
112
|
+
// Skip empty values, block scalars (|, >), sequences ([), maps ({).
|
|
113
|
+
if (!rawValue || rawValue[0] === "|" || rawValue[0] === ">" || rawValue[0] === "[" || rawValue[0] === "{")
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
data[key] = parseScalar(rawValue)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const bodyLines = lines.slice(closingIdx + 1)
|
|
120
|
+
const body = bodyLines.join("\n").trim()
|
|
121
|
+
|
|
122
|
+
return { data, body }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse a YAML scalar value into a TypeScript primitive.
|
|
127
|
+
*
|
|
128
|
+
* Handles quoted strings, booleans, and numbers. Anything that doesn't fit is
|
|
129
|
+
* returned as a trimmed string (safe fallback for unknown values).
|
|
130
|
+
*/
|
|
131
|
+
function parseScalar(raw: string): string | boolean | number {
|
|
132
|
+
// Quoted string: strip surrounding quotes (single or double).
|
|
133
|
+
// Require both opening and closing quote to be the same character and the
|
|
134
|
+
// string to be at least 2 chars long (a lone `"` or `'` is not a valid
|
|
135
|
+
// YAML scalar — treat it as a bare string to avoid a silent empty result).
|
|
136
|
+
const startsDouble = raw.startsWith('"')
|
|
137
|
+
const startsSingle = raw.startsWith("'")
|
|
138
|
+
const endsDouble = raw.endsWith('"')
|
|
139
|
+
const endsSingle = raw.endsWith("'")
|
|
140
|
+
if (raw.length >= 2) {
|
|
141
|
+
if (startsDouble && endsDouble) return raw.slice(1, -1)
|
|
142
|
+
if (startsSingle && endsSingle) return raw.slice(1, -1)
|
|
143
|
+
}
|
|
144
|
+
// Unbalanced quotes (e.g. `"missing closing`) are returned as-is — the
|
|
145
|
+
// caller receives the literal value including the opening quote, which is
|
|
146
|
+
// a better outcome than stripping half a pair and silently producing a
|
|
147
|
+
// wrong name.
|
|
148
|
+
|
|
149
|
+
// Boolean literals.
|
|
150
|
+
const lower = raw.toLowerCase()
|
|
151
|
+
if (lower === "true") return true
|
|
152
|
+
if (lower === "false") return false
|
|
153
|
+
|
|
154
|
+
// Numeric literal (integer or float; no octal/hex — YAML 1.2 only has these forms).
|
|
155
|
+
// The regex already guarantees a finite decimal, so Number() cannot return NaN here.
|
|
156
|
+
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
|
|
157
|
+
|
|
158
|
+
return raw
|
|
159
|
+
}
|