7.8 KiB
Spec: fastCRW websearch plugin for opencode v2
Compiled from the closed tickets on the map (Wayfinder map: fastCRW websearch plugin for opencode v2). Detail lives in each ticket and in docs/research/. Vocabulary is defined in CONTEXT.md. Reference skills: .claude/skills/opencode-v2-plugin, .claude/skills/fastcrw-search-api.
Goal
An opencode v2 plugin that makes a self-hosted fastCRW server opencode's only websearch search provider, via POST {baseURL}/v1/search. It is a native plugin, not the crw-mcp MCP server.
Identifiers
| What | Value |
|---|---|
| npm package | @haylan/opencode-fastcrw |
Plugin id (Plugin.define) |
fastcrw.websearch |
| Provider id / display name | fastcrw / "fastCRW" |
| Registry | https://git.arthurerlich.de/api/packages/haylan/npm/ |
Plugin behaviour
src/index.ts only wires things together:
export default Plugin.define({
id: "fastcrw.websearch",
async setup(ctx) {
const config = resolveConfig(ctx.options, process.env) // throws on bad values
await ctx.websearch.transform((editor) => {
editor.add({
id: "fastcrw",
name: "fastCRW",
execute: ({ query }, { signal }) => search(config, query, signal, fetch),
})
editor.default.set("fastcrw")
})
},
})
fastCRW is the default and only provider; there is no fallback to built-ins.
Config (src/config.ts)
Precedence: plugin option > env var > default.
| Option | Env var | Default | Rules |
|---|---|---|---|
baseURL |
CRW_API_URL |
http://localhost:3000 |
trim, strip trailing /, must be http(s) URL |
apiKey |
CRW_API_KEY |
none | optional; Bearer header only when set; never logged or echoed |
limit |
none | 8 |
clamp to 1..20; invalid falls back to 8, never throws |
lang |
none | unset | string; omitted from request when unset |
tbs |
none | unset | one of qdr:h, qdr:d, qdr:w, qdr:m, qdr:y; omitted when unset |
Validation runs in setup. A malformed baseURL, bad tbs or non-string lang makes setup throw, with a message naming the option and the bad value. Example opencode.json:
{
"plugins": [
{
"package": "@haylan/opencode-fastcrw",
"options": { "baseURL": "http://crw.home:3000", "limit": 8 },
},
],
"websearch": { "provider": "fastcrw" },
}
Search (src/search.ts)
- Request:
POST {baseURL}/v1/search, JSON{ query, limit, lang?, tbs? },Authorization: Bearer <apiKey>only when a key exists. Query sent as-is (server rejects over 2000 chars with 400). - Response: accept
dataas an array ordata.resultsas an array; any other shape throws "unexpected response shape". - Mapping:
{ url, title, content: snippet || description || "", time: {} }. Entries without a stringurlare dropped; a missingtitlefalls back tourl. Empty list returns[]. - Errors: throw
Error("fastCRW <status> <error_code>: <error>"); HTTP 200 withsuccess:falseis handled the same (status 200). Fixed hints only for 401 ("check apiKey or CRW_API_KEY") and 503search_disabled("the server has no search backend configured"). Non-JSON body:fastCRW <status>: non-JSON responseplus the first 200 chars. Network failure:fastCRW: cannot reach <baseURL>with the original error ascause. The token never appears in any message. - Abort: an abort via opencode's
signalis rethrown untouched. - Timeout: fixed 30 s, combined with
signalviaAbortSignal.any; not configurable. No retries; a 429 keeps its status in the message.
Repository
- Layout:
src/{config,search,index}.ts,test/*.test.ts,scripts/(release and smoke),docs/,CHANGELOG.md(Keep a Changelog 1.1.0),.gitea/workflows/. package.json:"type": "module",exports["."]→dist/index.js,engines.node >=20,version0.0.0-dev,@opencode/pluginindependencies. Scripts:build(tsc),format(prettier --write .),check(prettier --check . && tsc --noEmit),test(node --test),smoke.- TypeScript:
strict, target ES2022, moduleNodeNext, plaintsctodist/(ESM plus.d.ts). No bundler, no linter. - Prettier:
semi: false,printWidth: 100. No pre-commit hooks. - Git:
mainis protected (PR required, 0 approvals, no force-push or deletion; already set up). Commits use Conventional Commits.
Tests (node --test, Node 24, fetch mocked)
Cover: option/env/default precedence; each validation throw; limit clamp and fallback; request body and headers (Bearer only with a key; lang/tbs omitted when unset); both response shapes; every error path above; abort passthrough; the 30 s timeout.
Workflows (Gitea Actions, runner ubuntu-latest)
check.yml, onpull_requesttomain: Node 24,npm ci,npm run check,npm test,npm run build. Once it exists, add its status as a required check onmain.release.yml,workflow_dispatchonly, run onmain:- read the latest
## [x.y.z]heading inCHANGELOG.md(ignore[Unreleased]); - set
package.jsonversionto it in the CI workspace only (never committed back); npm ci,npm run build;- a dependency-free Node script in
scripts/builds the tarball,GETs the registry to check the version, exits 0 with "already published" if it exists, elsePUTs it to the registry API; - no
npm publish. Auth via a Gitea Actions secret.
- read the latest
Verification (manual, recorded in docs/runtime-checks.md)
Run once on a real opencode v2 install; a failed check reopens the design as a new ticket on the map.
- Can built-in providers be removed or disabled? Fallback:
"websearch": falseplus an explicitfastcrw. - Does a throwing
setupdisable only this plugin, not opencode startup? - Precedence of config
websearch.providervseditor.default.set. - Does install-by-name from the Gitea registry work with a scoped
.npmrc(@haylan:registry=...)? Fallback: tarball or local path. - Release token rights. Settled: the workflow uses a
PACKAGE_TOKENsecret withwrite:packageandwrite:repository; there is no job-token fallback. - Does
npm installof@opencode/plugindrag insolid-jsand the OpenTUI peers? If heavy, move it topeerDependenciesplusdevDependencies. npm run smokeagainst the real fastCRW server (CRW_API_URL/CRW_API_KEY): record the real response shape (data[]vsdata.results).- Confirm the exact
@opencode/pluginTypeScript types, the shape oftime, and that a 429 status in an error message triggers opencode's provider cooldown.
Build order
- Open a PR with the pending
CONTEXT.mdchange plus scaffold:package.json,tsconfig.json, Prettier config,CHANGELOG.mdwith[Unreleased]. src/config.tsandsrc/search.tstest-first, thensrc/index.ts.check.yml; make it a required check onmain.scripts/release and smoke scripts, thenrelease.yml.- Run the verification list; write
docs/runtime-checks.md. - First release: add a changelog entry, run the release workflow.
Out of scope
The crw-mcp MCP-server approach, pre-commit hooks, a bundler or linter, a configurable timeout, retries, and a fallback to built-in providers.