diff --git a/AGENTS.md b/AGENTS.md index e44f2b9..5030d3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Three files, 1 job each. Do not add a fourth unless the existing three genuinely | `src/constants.ts` | Pinned strings that must mirror upstream kimi-cli (version, endpoints, client id, scope). | | `src/headers.ts` | The seven `X-Msh-*` / UA headers + the persistent `~/.kimi/device_id` file. | | `src/oauth.ts` | Device-code start, device-code poll, refresh-token exchange, and `GET /coding/v1/models` discovery. | -| `src/index.ts` | Plugin entry. Wires `auth` (login + loader) plus the Kimi chat hooks/body rewrite. | +| `src/index.ts` | Plugin entry (v1 `PluginModule` format). Wires `auth` (login + loader) plus the Kimi chat hooks/body rewrite. | Data flow on a chat request: @@ -66,7 +66,7 @@ These are the invariants that, if broken, silently route requests onto the wrong 6. **Wire model id comes from `/coding/v1/models`, not from user config.** The opencode-side model id is a stable alias (`MODEL_ID = "kimi-for-coding"`); the plugin calls `GET /coding/v1/models` at login and on every token refresh (mirroring kimi-cli's `refresh_managed_models` in `research/kimi-cli/src/kimi_cli/auth/platforms.py`), caches the first returned `{id, context_length, display_name}` in loader memory, rewrites the JSON body `model` field inside `loader.fetch` whenever the discovered id differs from `MODEL_ID`, and backfills runtime model metadata from the same discovery response. A new loader instance re-discovers on first use if needed. Do not strip the `kimi-` prefix; send whatever the server returned. Discovery failures are non-fatal (warm cached id still works; 401 retry flushes broken tokens). 7. **Auth store is opencode's, not kimi-cli's.** We use opencode's auth store for tokens under the `kimi-for-coding-oauth` provider id. Do not read/write `~/.kimi/credentials/kimi-code.json`; that's kimi-cli's file and sharing it across independent apps causes token-race bugs. The plugin may live-read opencode's `auth.json` entry for this provider to bypass stale `OPENCODE_AUTH_CONTENT` workspace snapshots, but writes still go through opencode's auth store (`client.auth.set`). Also note that opencode's SDK auth schema only persists the standard oauth fields, so model discovery metadata cannot be stored there durably. 8. **Provider id must not collide with any id in the [models.dev](https://models.dev) catalog.** models.dev publishes `kimi-for-coding` as a separate API-key-driven integration. If we registered under that same id, `opencode auth login kimi-for-coding` would surface two methods under one entry and users could silently land on the wrong integration path. We deliberately use `kimi-for-coding-oauth` instead; `MODEL_ID` on the wire stays `kimi-for-coding` (rule 6). -9. **`src/index.ts` must have exactly one export — the default plugin function.** opencode's plugin loader (`research/opencode/packages/opencode/src/plugin/index.ts` → `getLegacyPlugins`) iterates every export of the plugin module and throws `Plugin export is not a function` if any named export is not callable. The failure mode is silent in the CLI (the provider just doesn't appear in `opencode auth login`); the error only surfaces in `~/.local/share/opencode/log/*.log`. Keep constants in `src/constants.ts` and import them in `src/index.ts` rather than re-exporting. `test/exports.test.ts` guards this. +9. **`src/index.ts` must have exactly one export — the default `PluginModule` object `{ id, server }`.** opencode's plugin loader (`research/opencode/packages/opencode/src/plugin/index.ts`) first tries `readV1Plugin` (detect mode) on the default export. If it finds an object with `server` (and optional `id`), it uses the v1 path directly. The older legacy path (`getLegacyPlugins`) iterates every export and throws `Plugin export is not a function` on any non-callable value — a problem that surfaced on Windows where Bun's standalone-binary dynamic imports can produce module namespace objects with unexpected non-function metadata. The v1 format bypasses `getLegacyPlugins` entirely. Keep constants in `src/constants.ts` and import them in `src/index.ts` rather than re-exporting. `test/exports.test.ts` guards this. The failure mode of a broken export is silent in the CLI (the provider just doesn't appear in `opencode auth login`); the error only surfaces in `~/.local/share/opencode/log/*.log`. 10. **The post-login config hint must not emit a partial `limit` object.** opencode's live config schema at `https://opencode.ai/config.json` requires both `limit.context` and `limit.output` whenever `limit` is present, while Kimi's `GET /coding/v1/models` only gives us `context_length`. Therefore `buildConfigBlock()` omits `limit` entirely and leaves `provider.models` to backfill `limit.context` at runtime. Do not invent `output` or set `input` heuristically; opencode's overflow logic treats `limit.input` as authoritative (`research/opencode/packages/opencode/src/session/overflow.ts`). 11. **Concurrent refreshes must collapse to one in-flight OAuth exchange, even across plugin instances.** `provider.models` and `auth.loader` can both notice an expiring token at about the same time, and separate opencode workspace/plugin instances can inherit stale auth snapshots. `refreshAuth()` in `src/index.ts` therefore shares one promise across overlapping callers, takes a provider-scoped auth-store lock before refreshing, re-reads opencode's live auth-store entry under that lock, and treats a changed on-disk token chain as authoritative. `test/plugin.test.ts` covers loader-vs-loader, provider.models-vs-loader, cross-instance lock reuse, and the `invalid_grant` self-heal path where another process already rotated the refresh token. @@ -88,7 +88,7 @@ These are the invariants that, if broken, silently route requests onto the wrong - ❌ Don't call out to other files to "share" the kimi-cli credentials. Different OAuth consumers must have independent refresh-token chains or one will invalidate the other. - ❌ Don't introduce a build step. The plugin ships as `.ts` and opencode's bun-based loader handles it. - ❌ Don't add tests that require real Kimi credentials and check them in. If you add offline unit tests, put them under `test/` and mock `fetch`. -- ❌ Don't add named exports to `src/index.ts`. See rule 9. +- ❌ Don't add named exports to `src/index.ts` or change the default export away from the `{ id, server }` PluginModule shape. See rule 9. ### How to verify a change diff --git a/package.json b/package.json index b984e02..3176e35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-kimi-full", - "version": "1.2.7", + "version": "1.2.8", "description": "OpenCode plugin that brings the official Kimi OAuth device flow and Kimi-specific coding request fields to opencode, matching upstream kimi-cli.", "license": "MIT", "repository": { @@ -18,7 +18,8 @@ "type": "module", "main": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./server": "./src/index.ts" }, "files": [ "src", diff --git a/src/index.ts b/src/index.ts index 20a1795..6e6b7e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,19 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin, PluginModule } from "@opencode-ai/plugin" import { API_BASE_URL, MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts" import { kimiHeaders } from "./headers.ts" import { type KimiModelInfo, listModels, pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts" -// IMPORTANT: this module must have exactly ONE export — the default plugin -// function. opencode's plugin loader (packages/opencode/src/plugin/index.ts → -// getLegacyPlugins) iterates every export and throws "Plugin export is not a -// function" if any named export is not a function. Keep constants in -// constants.ts and import them here. +// IMPORTANT: this module must have exactly ONE export — the default +// PluginModule object. opencode's plugin loader detects the v1 format +// ({ id, server }) via readV1Plugin *before* falling back to +// getLegacyPlugins — which iterates every export and throws "Plugin export +// is not a function" on any non-callable value. The v1 path is more +// reliable on Windows where Bun standalone dynamic imports can produce +// module namespace objects with unexpected non-function metadata. +// Keep constants in constants.ts and import them here. type OAuthAuth = { type: "oauth" @@ -688,4 +691,10 @@ const plugin: Plugin = async ({ client }) => { } } -export default plugin +// v1 PluginModule format — bypasses getLegacyPlugins entirely. +// For npm-sourced plugins, id is optional (falls back to package.json name), +// but we set it explicitly for clarity. +export default { + id: "opencode-kimi-full", + server: plugin, +} satisfies PluginModule diff --git a/test/exports.test.ts b/test/exports.test.ts index 28ffe58..1556c8a 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -1,17 +1,23 @@ import { test, expect } from "bun:test" import * as mod from "../src/index.ts" -// Regression guard for the 1.0.0 bug: -// opencode's plugin loader (packages/opencode/src/plugin/index.ts → -// getLegacyPlugins) iterates every export of the plugin module and throws -// "Plugin export is not a function" if any export is not callable. The -// published v1.0.0 re-exported PROVIDER_ID (a string), which broke loading -// silently — `opencode auth login` just did not list the provider. +// Regression guard for the 1.0.0 bug + the Windows loading fix: +// opencode's plugin loader first tries readV1Plugin (detect mode) on the +// default export. If it finds { id?, server } it uses the v1 path and +// never touches getLegacyPlugins. The legacy path iterates every export and +// throws "Plugin export is not a function" on any non-callable value — a +// problem that surfaced on Windows where Bun standalone dynamic imports can +// produce module namespaces with extra non-function metadata. // -// Keep this file cheap and dependency-free; do not import anything that -// makes network calls. -test("src/index.ts exports exactly one default export which is a function", () => { +// This test ensures the module exports exactly one default PluginModule +// object with a callable `server` and no named exports. +test("src/index.ts exports exactly one default PluginModule object", () => { const keys = Object.keys(mod) expect(keys).toEqual(["default"]) - expect(typeof (mod as { default: unknown }).default).toBe("function") + const plugin = (mod as { default: unknown }).default + expect(typeof plugin).toBe("object") + expect(plugin).not.toBeNull() + const obj = plugin as Record + expect(typeof obj.server).toBe("function") + expect("id" in obj).toBe(true) }) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 3c568cd..6905418 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { test, expect, afterEach } from "bun:test" -import plugin from "../src/index.ts" +import pluginModule from "../src/index.ts" + +const plugin = pluginModule.server import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "../src/constants.ts" import { installFetchMock } from "./_util/fetchMock.ts"