opencode-model-failover 0.0.0-stage → 0.1.0-beta.3
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 +199 -2
- package/index.js +156 -0
- package/package.json +52 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nabheet S. Sandhu
|
|
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
CHANGED
|
@@ -1,3 +1,200 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-model-failover
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Native model failover for [OpenCode](https://opencode.ai) **v2**.
|
|
4
|
+
|
|
5
|
+
When a session fails because a model is rate-limited, overloaded, or otherwise
|
|
6
|
+
unreachable, this plugin moves the session onto the next healthy model in a
|
|
7
|
+
chain you define. It runs entirely on the OpenCode v2 plugin API: no HTTP
|
|
8
|
+
client, no vendor SDK, no runtime dependencies.
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
OpenCode v2 has a different plugin API from v1, and the fallback plugins already
|
|
13
|
+
on npm are written against the v1 API, so they do not load in v2. This plugin is
|
|
14
|
+
v2-native.
|
|
15
|
+
|
|
16
|
+
- **No chain ships with it.** You name the models. Nothing about the author's
|
|
17
|
+
provider setup is baked in, and the default chain is empty.
|
|
18
|
+
- **Failures are filtered.** Only retryable errors (rate limits, timeouts,
|
|
19
|
+
provider and network errors, 429/502/503/504) trigger a failover. A validation
|
|
20
|
+
error would fail on every model, so it is ignored instead of burning attempts.
|
|
21
|
+
- **Prompt replay is opt-in.** `autoRetry` is off by default, because re-sending
|
|
22
|
+
a prompt is a state-changing action.
|
|
23
|
+
- **Cooldown.** A model whose switch fails is skipped for `cooldownMs`, so a
|
|
24
|
+
broken target is not retried on every failure.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- OpenCode **v2** (`opencode --version` reports 2.x).
|
|
29
|
+
- Node **>= 22**.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
### From npm
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
opencode plugin add opencode-model-failover
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### From GitHub
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
opencode plugin add github:nabheet/opencode-model-failover
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or add it to `opencode.json(c)` directly:
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
{
|
|
49
|
+
"$schema": "https://opencode.ai/config.json",
|
|
50
|
+
"plugins": ["opencode-model-failover"]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Manual
|
|
55
|
+
|
|
56
|
+
Copy this directory to `.opencode/plugins/opencode-model-failover/` (project) or
|
|
57
|
+
`~/.config/opencode/plugins/opencode-model-failover/` (global), then list it:
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
{
|
|
61
|
+
"plugins": [{ "package": "./.opencode/plugins/opencode-model-failover" }]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The v2 loader resolves `index.js` inside the directory; the `package.json`
|
|
66
|
+
`main` field is not consulted.
|
|
67
|
+
|
|
68
|
+
## Options
|
|
69
|
+
|
|
70
|
+
The plugin does nothing until you supply a chain. Pass options through the
|
|
71
|
+
plugin entry in your OpenCode config:
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
{
|
|
75
|
+
"plugins": [
|
|
76
|
+
{
|
|
77
|
+
"package": "opencode-model-failover",
|
|
78
|
+
"options": {
|
|
79
|
+
"fallbackModels": ["provider-a/model-x", "provider-b/model-y"],
|
|
80
|
+
"maxAttempts": 2,
|
|
81
|
+
"cooldownMs": 60000,
|
|
82
|
+
"autoRetry": false,
|
|
83
|
+
"notify": true,
|
|
84
|
+
"logFile": "/tmp/model-failover.log"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| Option | Type | Default | Description |
|
|
92
|
+
| ---------------- | ---------- | ------- | ----------------------------------------------- |
|
|
93
|
+
| `fallbackModels` | `string[]` | `[]` | Ordered `provider/model` chain, tried in order. |
|
|
94
|
+
| `maxAttempts` | `number` | `2` | Failovers per session before giving up. |
|
|
95
|
+
| `cooldownMs` | `number` | `60000` | Skip a model this long after a failed switch. |
|
|
96
|
+
| `autoRetry` | `boolean` | `false` | Replay the last user message after switching. |
|
|
97
|
+
| `notify` | `boolean` | `true` | Log each failover to stderr. |
|
|
98
|
+
| `logFile` | `string` | `""` | Also append log lines to this file. |
|
|
99
|
+
|
|
100
|
+
A `provider/model` string is split on the first `/`. Entries without a `/` are
|
|
101
|
+
dropped from the chain.
|
|
102
|
+
|
|
103
|
+
## How it works
|
|
104
|
+
|
|
105
|
+
The plugin subscribes to the v2 event stream and reacts to one event type:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
ctx.event.subscribe({ signal })
|
|
109
|
+
// session.execution.failed
|
|
110
|
+
// -> { data: { sessionID, error: { type, message } } }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
On a retryable failure it:
|
|
114
|
+
|
|
115
|
+
1. reads the models that actually resolve with `ctx.model.list()`;
|
|
116
|
+
2. picks the first chain entry that resolves and is not cooling down;
|
|
117
|
+
3. moves the session with `ctx.session.switchModel({ sessionID, model })`.
|
|
118
|
+
|
|
119
|
+
If `autoRetry` is on, it then reads the transcript with
|
|
120
|
+
`ctx.session.context({ sessionID })` and replays the last user message with
|
|
121
|
+
`ctx.session.prompt({ sessionID, text })`.
|
|
122
|
+
|
|
123
|
+
The `id`/`setup` plugin shape, the `plugins` config key, and the four methods
|
|
124
|
+
above are the whole v2 surface it uses.
|
|
125
|
+
|
|
126
|
+
## Observing it
|
|
127
|
+
|
|
128
|
+
A server-side plugin is easy to miss, so every decision is logged with a
|
|
129
|
+
`[model-failover]` prefix. Set `notify: false` to silence stderr, and
|
|
130
|
+
`logFile` (or the `MODEL_FALLBACK_LOG` environment variable) to append to a
|
|
131
|
+
file:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
MODEL_FALLBACK_LOG=/tmp/model-failover.log opencode
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Typical lines:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
[model-failover] 2026-01-01T00:00:00.000Z setup: chain=[provider-a/model-x] maxAttempts=2 autoRetry=false
|
|
141
|
+
[model-failover] 2026-01-01T00:00:01.000Z failed event: session=ses_123 error=provider.rateLimit msg=429
|
|
142
|
+
[model-failover] 2026-01-01T00:00:01.010Z availability=12 models; looking for next in chain
|
|
143
|
+
[model-failover] 2026-01-01T00:00:01.020Z session ses_123 moved to provider-a/model-x (attempt 1/2)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Compatibility
|
|
147
|
+
|
|
148
|
+
- Targets **OpenCode v2** (the `plugins` config key and the `id`/`setup` plugin
|
|
149
|
+
definition). OpenCode v1 used a different plugin API and is not supported.
|
|
150
|
+
- No runtime dependencies. Only `node:` builtins are imported.
|
|
151
|
+
|
|
152
|
+
## Prereleases
|
|
153
|
+
|
|
154
|
+
Every push to a PR publishes an installable beta to the npm `beta` dist-tag:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
opencode plugin add opencode-model-failover@beta
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npm install
|
|
164
|
+
npm run lint # biome check + markdownlint-cli2
|
|
165
|
+
npm run lint:fix # apply safe fixes
|
|
166
|
+
npm test # node:test
|
|
167
|
+
npm run check # lint + test
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Releases (maintainers)
|
|
171
|
+
|
|
172
|
+
All publishing happens in GitHub Actions via npm **trusted publishing** (OIDC
|
|
173
|
+
provenance). Never publish from a local shell.
|
|
174
|
+
|
|
175
|
+
`.github/workflows/ci.yml` is the single workflow file (npm allows one trusted
|
|
176
|
+
publisher per package; the workflow filename must stay `ci.yml`):
|
|
177
|
+
|
|
178
|
+
- `test` — lint + plugin tests on `main` and PRs (Node 24).
|
|
179
|
+
- push to `main` → `publish` job publishes the next patch to `latest`, pushes a
|
|
180
|
+
`vX.Y.Z` tag, and opens a GitHub release. The version is derived from the last
|
|
181
|
+
published `latest`, so repeated merges never collide; an intentional
|
|
182
|
+
minor/major bump in `package.json` is honored.
|
|
183
|
+
- tag `v*-beta*` → `publish` job publishes `beta` plus a prerelease GitHub
|
|
184
|
+
release.
|
|
185
|
+
- every PR push → `prerelease` job publishes `<version>-beta.<run_number>` to
|
|
186
|
+
`beta` (same-repo, non-draft, non-dependabot PRs; re-runs deduped).
|
|
187
|
+
- manual dispatch → next `-beta.X` staging publish from `main`, no git change.
|
|
188
|
+
|
|
189
|
+
One-time bootstrap: npm cannot attach a trusted publisher to a package that does
|
|
190
|
+
not exist yet, so `opencode-model-failover` must be published once by hand before
|
|
191
|
+
the workflow can publish. Then configure the trusted publisher on npmjs.com:
|
|
192
|
+
repository `nabheet/opencode-model-failover`, workflow filename `ci.yml`, and
|
|
193
|
+
tick **Allow `npm publish`**. A trusted publisher created after Sep 03 2026 is
|
|
194
|
+
stage-only by default, and stage-only sends CI publishes to the staging area to
|
|
195
|
+
wait for a 2FA approval instead of going live. A new config must also complete
|
|
196
|
+
its first successful publish within 2 days or it expires.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT — see [LICENSE](./LICENSE).
|
package/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// OpenCode v2 plugin. Options and behaviour: see README.md.
|
|
2
|
+
|
|
3
|
+
import { appendFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
// Ships empty: the chain is yours to define.
|
|
6
|
+
const DEFAULT_CHAIN = [];
|
|
7
|
+
|
|
8
|
+
// Only retryable failures are worth failing over on; a validation error fails on every model.
|
|
9
|
+
const RETRYABLE =
|
|
10
|
+
/no-route|provider\.|rate|quota|overload|unavailable|server|timeout|econn|socket|network|502|503|504|429/i;
|
|
11
|
+
|
|
12
|
+
function splitRef(ref) {
|
|
13
|
+
const at = String(ref).indexOf("/");
|
|
14
|
+
if (at <= 0) return null;
|
|
15
|
+
return { providerID: ref.slice(0, at), modelID: ref.slice(at + 1) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
id: "model-failover",
|
|
20
|
+
async setup(ctx) {
|
|
21
|
+
const opt = ctx.options || {};
|
|
22
|
+
const chain =
|
|
23
|
+
Array.isArray(opt.fallbackModels) && opt.fallbackModels.length > 0
|
|
24
|
+
? opt.fallbackModels.filter((m) => splitRef(m))
|
|
25
|
+
: DEFAULT_CHAIN;
|
|
26
|
+
const maxAttempts = Number.isFinite(opt.maxAttempts) ? opt.maxAttempts : 2;
|
|
27
|
+
const cooldownMs = Number.isFinite(opt.cooldownMs) ? opt.cooldownMs : 60_000;
|
|
28
|
+
const autoRetry = opt.autoRetry === true;
|
|
29
|
+
const notify = opt.notify !== false;
|
|
30
|
+
const logFile =
|
|
31
|
+
typeof opt.logFile === "string" ? opt.logFile : process.env.MODEL_FALLBACK_LOG || "";
|
|
32
|
+
|
|
33
|
+
const attempts = new Map(); // sessionID -> failovers used
|
|
34
|
+
const busy = new Set(); // sessionID -> failover in flight
|
|
35
|
+
const cooldown = new Map(); // "provider/model" -> epoch ms it is usable again
|
|
36
|
+
|
|
37
|
+
const log = (msg) => {
|
|
38
|
+
if (!logFile && !notify) return;
|
|
39
|
+
const line = `[model-failover] ${new Date().toISOString()} ${msg}`;
|
|
40
|
+
if (logFile) {
|
|
41
|
+
try {
|
|
42
|
+
appendFileSync(logFile, `${line}\n`);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
if (notify) console.error(line);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
log(`setup: chain=[${chain.join(", ")}] maxAttempts=${maxAttempts} autoRetry=${autoRetry}`);
|
|
49
|
+
if (chain.length === 0) {
|
|
50
|
+
log("no fallbackModels configured; set fallbackModels to enable failover");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function availableRefs() {
|
|
54
|
+
try {
|
|
55
|
+
const listed = await ctx.model.list();
|
|
56
|
+
const rows = listed && Array.isArray(listed.data) ? listed.data : [];
|
|
57
|
+
return new Set(rows.map((m) => `${m.providerID}/${m.modelID}`));
|
|
58
|
+
} catch {
|
|
59
|
+
return new Set();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function lastUserText(messages) {
|
|
64
|
+
const rows = Array.isArray(messages) ? messages : [];
|
|
65
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
66
|
+
const row = rows[i] || {};
|
|
67
|
+
const role = row.role || row.info?.role;
|
|
68
|
+
if (role !== "user") continue;
|
|
69
|
+
const parts = row.parts || row.info?.parts || [];
|
|
70
|
+
const text = parts
|
|
71
|
+
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
72
|
+
.map((p) => p.text)
|
|
73
|
+
.join("\n")
|
|
74
|
+
.trim();
|
|
75
|
+
if (text) return text;
|
|
76
|
+
}
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function failover(sessionID, error) {
|
|
81
|
+
const used = attempts.get(sessionID) || 0;
|
|
82
|
+
if (used >= maxAttempts) {
|
|
83
|
+
log(`max attempts reached for session ${sessionID} (${used}/${maxAttempts})`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const set = await availableRefs();
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
log(`availability=${set.size} models; looking for next in chain`);
|
|
90
|
+
const next = chain.find((ref) => set.has(ref) && (cooldown.get(ref) || 0) <= now);
|
|
91
|
+
if (!next) {
|
|
92
|
+
log("no available model left in chain");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ref = splitRef(next);
|
|
97
|
+
if (!ref) return;
|
|
98
|
+
|
|
99
|
+
attempts.set(sessionID, used + 1);
|
|
100
|
+
try {
|
|
101
|
+
await ctx.session.switchModel({
|
|
102
|
+
sessionID,
|
|
103
|
+
model: { providerID: ref.providerID, id: ref.modelID },
|
|
104
|
+
});
|
|
105
|
+
log(
|
|
106
|
+
`session ${sessionID} moved to ${next} (attempt ${used + 1}/${maxAttempts}) after ` +
|
|
107
|
+
`${error?.type || "error"}`,
|
|
108
|
+
);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
cooldown.set(next, Date.now() + cooldownMs);
|
|
111
|
+
log(`switchModel to ${next} failed: ${e?.message}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!autoRetry) return;
|
|
116
|
+
try {
|
|
117
|
+
const transcript = await ctx.session.context({ sessionID });
|
|
118
|
+
const text = lastUserText(transcript && (transcript.data || transcript));
|
|
119
|
+
if (text) await ctx.session.prompt({ sessionID, text });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
log(`autoRetry failed: ${e?.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
void (async () => {
|
|
127
|
+
try {
|
|
128
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
129
|
+
if (event?.type !== "session.execution.failed") continue;
|
|
130
|
+
const data = event.data || {};
|
|
131
|
+
const sessionID = data.sessionID;
|
|
132
|
+
const error = data.error || {};
|
|
133
|
+
log(
|
|
134
|
+
`failed event: session=${sessionID} error=${error.type} msg=${String(error.message).slice(0, 120)}`,
|
|
135
|
+
);
|
|
136
|
+
if (!sessionID || busy.has(sessionID)) continue;
|
|
137
|
+
|
|
138
|
+
const haystack = `${error.type || ""} ${error.message || ""}`;
|
|
139
|
+
if (!RETRYABLE.test(haystack)) {
|
|
140
|
+
log(`not retryable, ignoring: ${haystack.slice(0, 120)}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
busy.add(sessionID);
|
|
145
|
+
try {
|
|
146
|
+
await failover(sessionID, error);
|
|
147
|
+
} finally {
|
|
148
|
+
busy.delete(sessionID);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
})();
|
|
153
|
+
|
|
154
|
+
return () => controller.abort();
|
|
155
|
+
},
|
|
156
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-model-failover",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.3",
|
|
4
|
+
"description": "Native model failover for OpenCode v2: move a failed session onto the next healthy model in a chain you define.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"opencode",
|
|
17
|
+
"opencode-plugin",
|
|
18
|
+
"opencode-v2",
|
|
19
|
+
"model-failover",
|
|
20
|
+
"model-fallback",
|
|
21
|
+
"failover",
|
|
22
|
+
"rate-limit",
|
|
23
|
+
"quota",
|
|
24
|
+
"resilience",
|
|
25
|
+
"llm",
|
|
26
|
+
"ai-coding-agent"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Nabheet S. Sandhu",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/nabheet/opencode-model-failover.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/nabheet/opencode-model-failover/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/nabheet/opencode-model-failover#readme",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"lint": "biome check . && markdownlint-cli2 \"**/*.md\" \"#node_modules/**\" \"#dist/**\" \"#.git/**\"",
|
|
46
|
+
"lint:fix": "biome check --write . && markdownlint-cli2 --fix \"**/*.md\" \"#node_modules/**\" \"#dist/**\" \"#.git/**\"",
|
|
47
|
+
"check": "npm run lint && npm test",
|
|
48
|
+
"test": "node --test test/*.test.mjs"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@biomejs/biome": "^2.5.14",
|
|
52
|
+
"markdownlint-cli2": "^0.23.3"
|
|
53
|
+
}
|
|
54
|
+
}
|