- Node.js
>= 24,pnpm(v12) - A checkout of this repo; for live testing, a DeepSeek Harness profile (see README for the
link:setup)
All tasks go through pnpm (which delegates to the Vite+ toolchain):
pnpm install # install dependencies
pnpm run check # format + lint + types; must be zero *errors* (warnings are reported, not gating)
pnpm run test # Vitest suite, must be fully green and deterministic
pnpm run test:coverage # the same run, with the coverage ratchet enforced
pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, lib/client.js
pnpm run test:e2e # opt-in end-to-end suite; talks to the real OpenCode gateway
pnpm run catalog:shim # regenerate src/catalog-data.ts from models.devNote: in some shells
pnpm execstalls; invoke the binary directly if so:node node_modules/.pnpm/vite-plus@*/node_modules/vite-plus/bin/vp <cmd>.
pnpm run test:e2e runs test/e2e/**/*.e2e.ts through its own config (vitest.e2e.config.ts), so pnpm run test stays a fully offline, deterministic unit run. Nothing here is collected by the unit suite.
OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2eOPENCODE_E2E=1is required; without it every case skips, so a barepnpm run test:e2eis a safe no-op.OPENCODE_API_KEY(Zen) arms the live/modelsenrichment case and the paid protocol-routing cases;OPENCODE_GO_API_KEY(Go plan) arms the live/usagecase. Each block skips when its key is absent, which is why the CIe2ejob stays green on fork PRs (secrets are not exposed to them) while still checking the endpoints answer.- The free-tier and unmetered routing cases run keyless on purpose: measured 2026-10-06, a paid model answers
401 AuthErroron every endpoint without a credential, so keyless probing cannot tell them apart at all. Keyless discrimination is real only for the free classes. OPENCODE_ZEN_BASE_URL/OPENCODE_GO_BASE_URLretarget the suite at a mirror.
Two files, two jobs:
opencode-live.e2e.ts— the only place the plugin meets the real API. It catches what a stub cannot: the vendor changing the payload. The meter parses/zen/go/v1/usageand the picker consumes an enriched/modelslisting, so both shapes are asserted against the live service. The unkeyed cases assert reachability (never a 404) and that our header set does not change the gateway's verdict on a request — compared against the same call without it, rather than hardcoding a status the vendor may tighten.patched-fetch-headers.e2e.ts— a localnode:httplistener, so the outgoing header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a capturedfetch.
The web profile wires this checkout with link:, so builds are picked up like this:
- Host (
lib/index.mjs) — restart required. The base bundle shipshmr root: [](config watches only,**/node_modulesignored) and the loader caches ESM imports per process, so a linked package is never re-imported after boot. After everypnpm run build, restartdsh web— otherwise the profile silently keeps running the module from boot time. Symptom of a stale host: the plugin row is active but the settings card shows "This plugin is not loaded, so it cannot be configured." —dsh-settingsis filtering on the old module'sConfigschema. - Client (
lib/client.js) — refresh suffices. The client bundle is re-served from disk on every page load; a browser refresh picks it up. - Verify after a restart: the Plugins page shows the OpenCode Patch card, and its configure view renders all 17 fields (no unavailable line).
node --experimental-strip-types scripts/check.tscatches a stalelib/before you do (src/newer thanlib/).
- 100% strict TypeScript. No
anyleaks, noasassertions insrc/— narrowunknownwithin-operator type guards (isRecord,isUnknownArray, …). - Zero errors and zero warnings.
vp checkmust report neither — that is whatrelease:gateand CI enforce, and the warnings tier has been paid down to zero: do not reintroduce one. Theerrortier is reserved for defects: async safety and throw contracts. If a rule fights a correct pattern (e.g. sync Promise wrappers that preserveAsyncLocalStoragecontext), prefer a targetedoxlint-disablecomment with justification over weakening the rule globally. - Sync-over-async for context propagation.
withStoreiterators and thefetchpatch intentionally return promises from non-asyncfunctions soals.run()keeps turn context without an extra tick. Don't "fix" these intoasync. - Style: arrow-function consts (not
functiondeclarations), dot notation, explicit=== undefinedchecks,oxfmtformatting.
- Deterministic only. No
Math.random(), no fixedsleep()waits. Async file assertions poll with a deadline (waitForFileContent). - No secret fixtures. Session IDs are derived at runtime (
openCodeSessionIdFor) or read fromOPENCODE_SESSION_ID; never hardcodeses_…or API keys.lib/and*.logstay gitignored. - Restore globals. Tests that touch
globalThis.fetchorprocess.envmust restore them inafterEach(vi.unstubAllGlobals(),delete process.env.…). - Meaningful coverage. Every config field in the
SPECSregister (src/settings-fields.ts) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported fromusage-ui.tsare asserted directly, not re-derived in the test. - A component with hooks needs a real mount. Calling
Component(props)returns an element and runs none of its state, so a hook-bearing component can have twenty passing cases and still be untested —usage-pill.tsxsat at 9% exactly that way, with the poll loop, retry and dismissal all uncovered.test/usage-pill-mount.test.tsxis the answer: a// @vitest-environment jsdompragma scoped to that one file, so the rest of the suite keeps the fast node environment and the zero-dependency element-tree style.
pnpm run test:coverage runs the same suite with thresholds from vite.config.ts. They are a ratchet, not a target: they sit at the level the suite actually reaches, so they fail when coverage DROPS and nothing else. Two rules follow.
- Raise them when you genuinely add coverage — a PR that lifts statements by a few points should lift the threshold with it, or the next contributor inherits a gate nobody re-measured.
- Never set one above the current measurement. That does not make the suite better; it makes every subsequent PR fail until someone deletes tests.
src/index.ts is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. responses-provider.ts and responses-routes.ts carry their own per-file floors: the mount has four host contracts to survive, and a well-covered average must not be able to hide it.
- Use conventional commits (
feat:,fix:) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. - For manual releases: bump
versioninpackage.json, add aCHANGELOG.mdentry, commit,git tag vX.Y.Z && git push origin vX.Y.Z. - On every release, refresh the Last verified date and matrix in
README.mdafter smoke-testing: registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds.
README.mdis consumer-facing: problem-first, install, UI config, troubleshooting. No dev internals.CHANGELOG.mdis the per-version record.AGENTS.mdis the agent-facing project brief — keep it specific to this repo.