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