Files
fastcrw-opencode-websearch/docs/spec.md
T
2026-10-04 12:24:40 +02:00

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 data as an array or data.results as an array; any other shape throws "unexpected response shape".
  • Mapping: { url, title, content: snippet || description || "", time: {} }. Entries without a string url are dropped; a missing title falls back to url. Empty list returns [].
  • Errors: throw Error("fastCRW <status> <error_code>: <error>"); HTTP 200 with success:false is handled the same (status 200). Fixed hints only for 401 ("check apiKey or CRW_API_KEY") and 503 search_disabled ("the server has no search backend configured"). Non-JSON body: fastCRW <status>: non-JSON response plus the first 200 chars. Network failure: fastCRW: cannot reach <baseURL> with the original error as cause. The token never appears in any message.
  • Abort: an abort via opencode's signal is rethrown untouched.
  • Timeout: fixed 30 s, combined with signal via AbortSignal.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, version 0.0.0-dev, @opencode/plugin in dependencies. Scripts: build (tsc), format (prettier --write .), check (prettier --check . && tsc --noEmit), test (node --test), smoke.
  • TypeScript: strict, target ES2022, module NodeNext, plain tsc to dist/ (ESM plus .d.ts). No bundler, no linter.
  • Prettier: semi: false, printWidth: 100. No pre-commit hooks.
  • Git: main is 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)

  1. check.yml, on pull_request to main: Node 24, npm ci, npm run check, npm test, npm run build. Once it exists, add its status as a required check on main.
  2. release.yml, workflow_dispatch only, run on main:
    • read the latest ## [x.y.z] heading in CHANGELOG.md (ignore [Unreleased]);
    • set package.json version to 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, else PUTs it to the registry API;
    • no npm publish. Auth via a Gitea Actions secret.

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.

  1. Can built-in providers be removed or disabled? Fallback: "websearch": false plus an explicit fastcrw.
  2. Does a throwing setup disable only this plugin, not opencode startup?
  3. Precedence of config websearch.provider vs editor.default.set.
  4. Does install-by-name from the Gitea registry work with a scoped .npmrc (@haylan:registry=...)? Fallback: tarball or local path.
  5. Release token rights. Settled: the workflow uses a PACKAGE_TOKEN secret with write:package and write:repository; there is no job-token fallback.
  6. Does npm install of @opencode/plugin drag in solid-js and the OpenTUI peers? If heavy, move it to peerDependencies plus devDependencies.
  7. npm run smoke against the real fastCRW server (CRW_API_URL/CRW_API_KEY): record the real response shape (data[] vs data.results).
  8. Confirm the exact @opencode/plugin TypeScript types, the shape of time, and that a 429 status in an error message triggers opencode's provider cooldown.

Build order

  1. Open a PR with the pending CONTEXT.md change plus scaffold: package.json, tsconfig.json, Prettier config, CHANGELOG.md with [Unreleased].
  2. src/config.ts and src/search.ts test-first, then src/index.ts.
  3. check.yml; make it a required check on main.
  4. scripts/ release and smoke scripts, then release.yml.
  5. Run the verification list; write docs/runtime-checks.md.
  6. 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.