From be0b711d12b198f2f5de7d7b1494265b2e71d29d Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 26 Sep 2026 08:30:30 -0300 Subject: [PATCH] feat: add OpenCode V2 plugin support with dual V1+V2 entry V2 setup via provider transform with V2 model inventory; workit-style packageRoot catalog resolution; @/ import aliases for tests/scripts --- index.ts | 4 + package.json | 16 +- plugin.ts | 219 +++++++++++++------- scripts/build-plugin.ts | 35 ++++ scripts/catalog-release-notes.ts | 6 +- scripts/catalog-sync-ci.ts | 4 +- scripts/cut-changelog.ts | 2 +- scripts/prepare-release-manifest.ts | 2 +- scripts/sync-models.ts | 8 +- src/entry.ts | 3 + src/paths.ts | 43 ++++ src/v2models.ts | 66 ++++++ tests/integration/model.integration.test.ts | 2 +- tests/unit/analyze-release-scope.test.ts | 2 +- tests/unit/auth.test.ts | 2 +- tests/unit/catalog-break.test.ts | 2 +- tests/unit/catalog-release-notes.test.ts | 2 +- tests/unit/catalog.test.ts | 2 +- tests/unit/convert.test.ts | 2 +- tests/unit/costs-docs.test.ts | 4 +- tests/unit/costs-models-dev.test.ts | 4 +- tests/unit/cut-changelog.test.ts | 2 +- tests/unit/index.test.ts | 2 +- tests/unit/manifest.test.ts | 2 +- tests/unit/model.test.ts | 4 +- tests/unit/plugin-v2.test.ts | 103 +++++++++ tests/unit/plugin.test.ts | 15 +- tests/unit/publish-policy.test.ts | 2 +- tests/unit/release-workflow.test.ts | 2 +- tests/unit/startup.test.ts | 2 +- tests/unit/stream.test.ts | 4 +- tests/unit/sync-models.test.ts | 6 +- tests/unit/v2models.test.ts | 58 ++++++ tsconfig.json | 5 +- 34 files changed, 511 insertions(+), 126 deletions(-) create mode 100644 scripts/build-plugin.ts create mode 100644 src/entry.ts create mode 100644 src/paths.ts create mode 100644 src/v2models.ts create mode 100644 tests/unit/plugin-v2.test.ts create mode 100644 tests/unit/v2models.test.ts diff --git a/index.ts b/index.ts index c1cb194..a4974e9 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,10 @@ import { resolveApiKey } from "./src/auth.js"; import { CommandCodeLanguageModel } from "./src/model.js"; +// Re-export the plugin definition so a directory plugin entry (/index.ts) +// loads the dual V1+V2 plugin. No cycle: plugin.ts only imports from src/. +export { default, server } from "./plugin.js"; + export interface CommandCodeProviderOptions { name?: string; apiKey?: string; diff --git a/package.json b/package.json index 1121a70..febf5c1 100644 --- a/package.json +++ b/package.json @@ -29,30 +29,34 @@ "url": "git+https://github.com/BrainerVirus/opencode-commandcode.git" }, "files": [ - "index.ts", - "plugin.ts", + "dist/", "models.json", "manifest.json", "_version.txt", "README.md", "CHANGELOG.md", + "index.ts", + "plugin.ts", "src/" ], "type": "module", - "main": "index.ts", - "types": "index.ts", + "main": "./dist/plugin.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./index.ts" + "types": "./dist/index.d.ts", + "import": "./dist/plugin.js" }, "./server": { - "import": "./plugin.ts" + "types": "./dist/plugin.d.ts", + "import": "./dist/plugin.js" } }, "publishConfig": { "access": "public" }, "scripts": { + "build": "bun scripts/build-plugin.ts && bunx tsc -p tsconfig.json --emitDeclarationOnly", "lint": "oxlint --deny-warnings src scripts plugin.ts index.ts tests", "lint:fix": "oxlint --fix src scripts plugin.ts index.ts tests", "format": "oxfmt src scripts plugin.ts index.ts tests package.json tsconfig.json release.config.cjs", diff --git a/plugin.ts b/plugin.ts index 5f67818..2540971 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,7 +1,6 @@ import { readFileSync, existsSync } from "fs"; import { homedir } from "os"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; +import { join } from "path"; import { generateOpencodeModels, loadCatalogFromLocalCommandCode, @@ -14,11 +13,10 @@ import { pluginStateDir, } from "./src/startup.js"; import type { CatalogManifest } from "./src/manifest.js"; +import { generateV2Models } from "./src/v2models.js"; +import { catalogPaths } from "./src/paths.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const MODELS_PATH = join(__dirname, "models.json"); -const VERSION_PATH = join(__dirname, "_version.txt"); -const MANIFEST_PATH = join(__dirname, "manifest.json"); +const { models: MODELS_PATH, manifest: MANIFEST_PATH, version: VERSION_PATH } = catalogPaths(); interface PluginFileConfig { disableModelSync?: boolean; @@ -72,7 +70,94 @@ function readBundledVersion(): string | null { } } -export default async function commandcodePlugin() { +type CatalogLoad = { + models: ModelEntry[]; + catalogSource: "bundled" | "cache" | "opt-in-local"; + commandCodeVersion: string | null; + degraded: boolean; + degradedReason: string | null; +}; + +function loadCatalogEntries(): CatalogLoad { + const pluginCfg = loadPluginConfig(); + const override = + pluginCfg.commandCodePackagePath?.trim() || process.env.COMMANDCODE_PACKAGE_PATH?.trim() || ""; + + let models: ModelEntry[] = []; + let catalogSource: CatalogLoad["catalogSource"] = "bundled"; + let commandCodeVersion: string | null = null; + let degraded = false; + let degradedReason: string | null = null; + + if (override) { + const localCatalog = loadCatalogFromLocalCommandCode({ packagePath: override }); + if (localCatalog && localCatalog.models.length > 0) { + models = localCatalog.models; + catalogSource = "opt-in-local"; + commandCodeVersion = localCatalog.version; + } + } + + if (models.length === 0) { + const bundled = loadBundledModels(); + if (bundled) { + models = bundled; + catalogSource = "bundled"; + commandCodeVersion = readBundledVersion(); + const manifest = readBundledManifest(); + if (manifest?.status === "degraded" || manifest?.status === "broken") { + degraded = true; + degradedReason = + manifest.status === "broken" + ? "bundled catalog marked broken" + : "bundled catalog has models with no listed price"; + } + } else { + const cached = readCatalogCache(); + if (cached) { + models = cached; + catalogSource = "cache"; + degraded = true; + degradedReason = "bundled models.json unreadable; using last-good cache"; + } else { + degraded = true; + degradedReason = "no bundled catalog and no cache"; + } + } + } + + return { models, catalogSource, commandCodeVersion, degraded, degradedReason }; +} + +function persistCatalogSideEffects(load: CatalogLoad, debug: boolean): void { + if (load.models.length > 0) { + try { + writeCatalogCache(pluginStateDir(), load.models); + } catch { + // ignore cache write + } + } + + const summary = { + catalogSource: load.catalogSource, + commandCodeVersion: load.commandCodeVersion, + modelCount: load.models.length, + reasoningModelCount: load.models.filter((m) => m.reasoning).length, + degraded: load.degraded, + degradedReason: load.degradedReason, + }; + try { + writeStartupSummary(pluginStateDir(), summary); + } catch { + // ignore + } + if (debug) { + console.warn("[commandcode]", JSON.stringify(summary)); + } +} + +/** V1 entrypoint (OpenCode 1.18.29+ also accepts it via `server`). */ +export async function server() { return { config: async (config: Record) => { if (!(config as Record).provider) { @@ -85,10 +170,6 @@ export default async function commandcodePlugin() { const pluginCfg = loadPluginConfig(); const debug = pluginCfg.debugStartupLogs === true; - const override = - pluginCfg.commandCodePackagePath?.trim() || - process.env.COMMANDCODE_PACKAGE_PATH?.trim() || - ""; if (!cc.npm) cc.npm = "commandcode-go-opencode-provider"; if (!cc.name) cc.name = "Command Code"; @@ -96,75 +177,9 @@ export default async function commandcodePlugin() { if (cc.models) return; - let models: ModelEntry[] = []; - let catalogSource: "bundled" | "cache" | "opt-in-local" = "bundled"; - let commandCodeVersion: string | null = null; - let degraded = false; - let degradedReason: string | null = null; - - if (override) { - const localCatalog = loadCatalogFromLocalCommandCode({ packagePath: override }); - if (localCatalog && localCatalog.models.length > 0) { - models = localCatalog.models; - catalogSource = "opt-in-local"; - commandCodeVersion = localCatalog.version; - } - } - - if (models.length === 0) { - const bundled = loadBundledModels(); - if (bundled) { - models = bundled; - catalogSource = "bundled"; - commandCodeVersion = readBundledVersion(); - const manifest = readBundledManifest(); - if (manifest?.status === "degraded" || manifest?.status === "broken") { - degraded = true; - degradedReason = - manifest.status === "broken" - ? "bundled catalog marked broken" - : "bundled catalog has models with no listed price"; - } - } else { - const cached = readCatalogCache(); - if (cached) { - models = cached; - catalogSource = "cache"; - degraded = true; - degradedReason = "bundled models.json unreadable; using last-good cache"; - } else { - degraded = true; - degradedReason = "no bundled catalog and no cache"; - } - } - } - - if (models.length > 0) { - try { - writeCatalogCache(pluginStateDir(), models); - } catch { - // ignore cache write - } - } - - cc.models = generateOpencodeModels(models); - - const summary = { - catalogSource, - commandCodeVersion, - modelCount: models.length, - reasoningModelCount: models.filter((m) => m.reasoning).length, - degraded, - degradedReason, - }; - try { - writeStartupSummary(pluginStateDir(), summary); - } catch { - // ignore - } - if (debug) { - console.warn("[commandcode]", JSON.stringify(summary)); - } + const load = loadCatalogEntries(); + persistCatalogSideEffects(load, debug); + cc.models = generateOpencodeModels(load.models); }, auth: { @@ -195,3 +210,47 @@ export default async function commandcodePlugin() { }, }; } + +/** V2 setup: provider inventory + models through transforms. Auth stays V1-only for now. */ +async function setup(ctx: any): Promise { + const pluginCfg = loadPluginConfig(); + const debug = pluginCfg.debugStartupLogs === true; + const load = loadCatalogEntries(); + persistCatalogSideEffects(load, debug); + const v2models = generateV2Models(load.models); + + await ctx.provider.transform((editor: any) => { + const existing = editor.get("commandcode"); + if (!existing) { + editor.add({ + info: { + id: "commandcode", + name: "Command Code", + activation: "enabled", + package: "aisdk:@ai-sdk/openai-compatible", + settings: { + baseURL: "https://api.commandcode.ai/provider/v1", + apiKey: "{env:COMMANDCODE_API_KEY}", + }, + }, + models: v2models, + }); + return; + } + editor.update("commandcode", (provider: any) => { + provider.name = provider.name ?? "Command Code"; + provider.settings = provider.settings ?? {}; + if (typeof provider.settings === "object") { + if (!provider.settings.baseURL) + provider.settings.baseURL = "https://api.commandcode.ai/provider/v1"; + if (!provider.settings.apiKey) provider.settings.apiKey = "{env:COMMANDCODE_API_KEY}"; + } + }); + editor.models.set("commandcode", v2models); + }); +} + +const definition = { id: "commandcode", setup }; + +// Dual entry: V2 reads default.id/setup, V1 (>=1.18.29) calls default.server(). +export default { ...definition, server }; diff --git a/scripts/build-plugin.ts b/scripts/build-plugin.ts new file mode 100644 index 0000000..97b0940 --- /dev/null +++ b/scripts/build-plugin.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env bun +// Build the self-contained OpenCode plugin entry (dist/plugin.js). +// Bundles src/entry.ts (plugin default + V1 server + AI-SDK factory) for +// node ESM; the `ai` package stays external and node builtins stay external +// via --target node. All other imports are type-only (erased at build). +import { spawnSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dir, ".."); + +mkdirSync(join(ROOT, "dist"), { recursive: true }); + +const build = spawnSync( + "bun", + [ + "build", + join(ROOT, "src/entry.ts"), + "--outfile", + join(ROOT, "dist/plugin.js"), + "--target", + "node", + "--format", + "esm", + "--external", + "ai", + ], + { encoding: "utf8", cwd: ROOT }, +); +if (build.stdout) process.stdout.write(build.stdout); +if (build.stderr) process.stderr.write(build.stderr); +if (build.status !== 0) { + throw new Error(`bun build src/entry.ts failed (exit ${build.status ?? "unknown"})`); +} +console.log("opencode: built dist/plugin.js"); diff --git a/scripts/catalog-release-notes.ts b/scripts/catalog-release-notes.ts index 1f06db2..fbe9bba 100644 --- a/scripts/catalog-release-notes.ts +++ b/scripts/catalog-release-notes.ts @@ -1,9 +1,9 @@ #!/usr/bin/env bun import { readFileSync } from "fs"; import { join } from "path"; -import { catalogReleaseNotesFromFiles } from "../src/catalog-release-notes.js"; -import type { CatalogManifest } from "../src/manifest.js"; -import type { PricedModel } from "../src/catalog-release-notes.js"; +import { catalogReleaseNotesFromFiles } from "@/src/catalog-release-notes.js"; +import type { CatalogManifest } from "@/src/manifest.js"; +import type { PricedModel } from "@/src/catalog-release-notes.js"; const ROOT = join(import.meta.dir, ".."); const manifest = JSON.parse(readFileSync(join(ROOT, "manifest.json"), "utf-8")) as CatalogManifest; diff --git a/scripts/catalog-sync-ci.ts b/scripts/catalog-sync-ci.ts index cf30305..681f113 100644 --- a/scripts/catalog-sync-ci.ts +++ b/scripts/catalog-sync-ci.ts @@ -2,8 +2,8 @@ import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { execSync } from "child_process"; -import { catalogBreakTitle, renderCatalogBreakBody } from "../src/catalog-break.js"; -import { decideCatalogSync } from "../src/publish-policy.js"; +import { catalogBreakTitle, renderCatalogBreakBody } from "@/src/catalog-break.js"; +import { decideCatalogSync } from "@/src/publish-policy.js"; import { npmLatestVersion, npmPackageVersions } from "./npm-registry.js"; const ROOT = join(import.meta.dir, ".."); diff --git a/scripts/cut-changelog.ts b/scripts/cut-changelog.ts index e71effa..3bf23e9 100644 --- a/scripts/cut-changelog.ts +++ b/scripts/cut-changelog.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { cutKeepAChangelog } from "../src/cut-changelog.ts"; +import { cutKeepAChangelog } from "@/src/cut-changelog.ts"; const version = process.argv[2]; const date = process.argv[3] ?? new Date().toISOString().slice(0, 10); diff --git a/scripts/prepare-release-manifest.ts b/scripts/prepare-release-manifest.ts index 5b3a593..4b1b90f 100644 --- a/scripts/prepare-release-manifest.ts +++ b/scripts/prepare-release-manifest.ts @@ -4,7 +4,7 @@ // untouched; the post-release sync PR commits the stamped versions to main. import { existsSync, readFileSync } from "fs"; import { join } from "path"; -import { withPluginVersion, writeManifest, type CatalogManifest } from "../src/manifest.js"; +import { withPluginVersion, writeManifest, type CatalogManifest } from "@/src/manifest.js"; const ROOT = join(import.meta.dir, ".."); diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index b4ecdcf..44668ab 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -13,15 +13,15 @@ import { loadCatalogFromLocalCommandCode, parseAvailabilityIds, type ModelEntry, -} from "../src/catalog.js"; -import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "../src/costs-docs.js"; +} from "@/src/catalog.js"; +import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "@/src/costs-docs.js"; import { applyFreeCosts, applyModelsDevCosts, applyModelsDevModalities, fetchModelsDevJson, parseModelsDev, -} from "../src/costs-models-dev.js"; +} from "@/src/costs-models-dev.js"; import { buildManifest, commandCodeTarballUrl, @@ -31,7 +31,7 @@ import { withUnavailableIds, writeManifest, type CatalogManifest, -} from "../src/manifest.js"; +} from "@/src/manifest.js"; export type SyncArtifacts = { models: ModelEntry[]; diff --git a/src/entry.ts b/src/entry.ts new file mode 100644 index 0000000..a723018 --- /dev/null +++ b/src/entry.ts @@ -0,0 +1,3 @@ +export { default } from "../plugin.js"; +export { server } from "../plugin.js"; +export { createCommandCode } from "../index.js"; diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..839c5ce --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,43 @@ +import { existsSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Directory of this module, canonicalized so symlinked checkouts, tmpdir + * aliasing (`/var` vs `/private/var`), and Windows 8.3 short names cannot + * split the reported locations from the caller's spelling. */ +const moduleDir = (): string => { + try { + return realpathSync(path.dirname(fileURLToPath(import.meta.url))); + } catch { + return path.dirname(fileURLToPath(import.meta.url)); + } +}; + +/** + * Resolve the provider package root from any layout, workit-style: source + * modules live in `src/`, the plugin entry at the package root (`plugin.ts`), + * and a future bundle in `dist/`. The nearest ancestor with a `package.json` + * wins, so moving a source file or bundling the entry can never silently + * point catalog reads at the wrong directory. + */ +export const packageRoot = (): string => { + const dir = moduleDir(); + for (const candidate of [ + path.resolve(dir, ".."), + path.resolve(dir, "..", ".."), + path.resolve(dir, "..", "..", ".."), + ]) { + if (existsSync(path.join(candidate, "package.json"))) return candidate; + } + return path.resolve(dir, ".."); +}; + +/** Catalog artifact locations under the resolved package root. */ +export const catalogPaths = (): { models: string; manifest: string; version: string } => { + const root = packageRoot(); + return { + models: path.join(root, "models.json"), + manifest: path.join(root, "manifest.json"), + version: path.join(root, "_version.txt"), + }; +}; diff --git a/src/v2models.ts b/src/v2models.ts new file mode 100644 index 0000000..419e799 --- /dev/null +++ b/src/v2models.ts @@ -0,0 +1,66 @@ +import { toConfigKey, type ModelEntry } from "./catalog.js"; + +/** Minimal V2 model shape (structural subset of Model.Info). */ +export interface V2Model { + id: string; + modelID: string; + providerID: string; + name: string; + capabilities: { tools: boolean; input: string[]; output: string[] }; + cost: Array<{ input: number; output: number; cache: { read: number; write: number } }>; + limit: { context: number; output: number }; + variants: Array<{ id: string; settings: Record }>; + status: "active"; + enabled: boolean; + time: { released: number }; +} + +function v2Input(entry: ModelEntry): string[] { + if (entry.modalities?.input && entry.modalities.input.length > 0) + return [...entry.modalities.input]; + if (entry.attachment === true) return ["text", "image"]; + return ["text"]; +} + +function v2Output(entry: ModelEntry): string[] { + if (entry.modalities?.output && entry.modalities.output.length > 0) + return [...entry.modalities.output]; + return ["text"]; +} + +export function toV2Model(entry: ModelEntry): V2Model { + const id = toConfigKey(entry.id); + return { + id, + modelID: id, + providerID: "commandcode", + name: entry.name, + capabilities: { + tools: entry.tool_call, + input: v2Input(entry), + output: v2Output(entry), + }, + cost: [ + { + input: entry.cost.input, + output: entry.cost.output, + cache: { + read: entry.cost.cache_read ?? 0, + write: entry.cost.cache_write ?? 0, + }, + }, + ], + limit: { context: entry.limit.context, output: entry.limit.output }, + variants: (entry.reasoningEfforts ?? []).map((effort) => ({ + id: effort, + settings: { reasoningEffort: effort }, + })), + status: "active", + enabled: true, + time: { released: 0 }, + }; +} + +export function generateV2Models(entries: ModelEntry[]): V2Model[] { + return entries.map(toV2Model); +} diff --git a/tests/integration/model.integration.test.ts b/tests/integration/model.integration.test.ts index 64ca681..e1b6b85 100644 --- a/tests/integration/model.integration.test.ts +++ b/tests/integration/model.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { CommandCodeLanguageModel } from "../../src/model.js"; +import { CommandCodeLanguageModel } from "@/src/model.js"; const API_KEY = process.env.COMMANDCODE_API_KEY; const hasApiKey = !!API_KEY; diff --git a/tests/unit/analyze-release-scope.test.ts b/tests/unit/analyze-release-scope.test.ts index 5177713..913ed7b 100644 --- a/tests/unit/analyze-release-scope.test.ts +++ b/tests/unit/analyze-release-scope.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { analyzeReleaseScope, latestTag } from "../../scripts/analyze-release-scope.ts"; +import { analyzeReleaseScope, latestTag } from "@/scripts/analyze-release-scope.ts"; type Repo = { root: string; diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index 14128b0..8e2e837 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,5 +1,5 @@ import { expect, test, beforeEach } from "bun:test"; -import { resolveApiKey } from "../../src/auth.js"; +import { resolveApiKey } from "@/src/auth.js"; beforeEach(() => { delete process.env.COMMANDCODE_API_KEY; diff --git a/tests/unit/catalog-break.test.ts b/tests/unit/catalog-break.test.ts index d9d3557..ebc3197 100644 --- a/tests/unit/catalog-break.test.ts +++ b/tests/unit/catalog-break.test.ts @@ -3,7 +3,7 @@ import { catalogBreakResolvedComment, catalogBreakTitle, renderCatalogBreakBody, -} from "../../src/catalog-break.ts"; +} from "@/src/catalog-break.ts"; describe("catalogBreakTitle", () => { test("includes the failed command-code version", () => { diff --git a/tests/unit/catalog-release-notes.test.ts b/tests/unit/catalog-release-notes.test.ts index 0b25870..2eaeaf7 100644 --- a/tests/unit/catalog-release-notes.test.ts +++ b/tests/unit/catalog-release-notes.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { renderCatalogReleaseNotes, type CatalogReleaseInput, -} from "../../src/catalog-release-notes.ts"; +} from "@/src/catalog-release-notes.ts"; const ROOT = join(import.meta.dir, "../.."); diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 65e5b2f..9ba96ad 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -10,7 +10,7 @@ import { type CostEntry, type ModelEntry, type SnEntry, -} from "../../src/catalog.ts"; +} from "@/src/catalog.ts"; function costMapOf(entries: CostEntry[]): Map { const map = new Map(); diff --git a/tests/unit/convert.test.ts b/tests/unit/convert.test.ts index 4913772..15cd85e 100644 --- a/tests/unit/convert.test.ts +++ b/tests/unit/convert.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { buildRequest } from "../../src/convert.js"; +import { buildRequest } from "@/src/convert.js"; import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"; function makeOpts(overrides: Partial = {}): LanguageModelV3CallOptions { diff --git a/tests/unit/costs-docs.test.ts b/tests/unit/costs-docs.test.ts index 6a51425..2177a07 100644 --- a/tests/unit/costs-docs.test.ts +++ b/tests/unit/costs-docs.test.ts @@ -1,8 +1,8 @@ import { expect, test, describe } from "bun:test"; import { readFileSync } from "fs"; import { join } from "path"; -import { applyDocCosts, parseModelsTable, parseMoneyCell } from "../../src/costs-docs.ts"; -import type { ModelEntry } from "../../src/catalog.ts"; +import { applyDocCosts, parseModelsTable, parseMoneyCell } from "@/src/costs-docs.ts"; +import type { ModelEntry } from "@/src/catalog.ts"; describe("parseMoneyCell", () => { test("reads billed amount when a deal shows two prices", () => { diff --git a/tests/unit/costs-models-dev.test.ts b/tests/unit/costs-models-dev.test.ts index 9490905..ec0108c 100644 --- a/tests/unit/costs-models-dev.test.ts +++ b/tests/unit/costs-models-dev.test.ts @@ -8,8 +8,8 @@ import { isFreeSku, parseModelsDev, TEXT_ONLY_MODALITIES, -} from "../../src/costs-models-dev.ts"; -import type { ModelEntry } from "../../src/catalog.ts"; +} from "@/src/costs-models-dev.ts"; +import type { ModelEntry } from "@/src/catalog.ts"; function model(partial: Partial & Pick): ModelEntry { return { diff --git a/tests/unit/cut-changelog.test.ts b/tests/unit/cut-changelog.test.ts index e924b59..d07ac4d 100644 --- a/tests/unit/cut-changelog.test.ts +++ b/tests/unit/cut-changelog.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "bun:test"; -import { cutKeepAChangelog } from "../../src/cut-changelog.ts"; +import { cutKeepAChangelog } from "@/src/cut-changelog.ts"; const HEADER = `# Changelog diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 435c4aa..899869c 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,5 +1,5 @@ import { expect, test, beforeAll, afterAll } from "bun:test"; -import { createCommandCode } from "../../index.js"; +import { createCommandCode } from "@/index.js"; let originalEnv: Record = {}; beforeAll(() => { diff --git a/tests/unit/manifest.test.ts b/tests/unit/manifest.test.ts index 7ae87f2..b4b2d5f 100644 --- a/tests/unit/manifest.test.ts +++ b/tests/unit/manifest.test.ts @@ -11,7 +11,7 @@ import { withUnavailableIds, writeManifest, type CostSources, -} from "../../src/manifest.ts"; +} from "@/src/manifest.ts"; const sources = (partial: Partial = {}): CostSources => ({ cli: 0, diff --git a/tests/unit/model.test.ts b/tests/unit/model.test.ts index 648569d..9935a97 100644 --- a/tests/unit/model.test.ts +++ b/tests/unit/model.test.ts @@ -1,11 +1,11 @@ import { expect, test, beforeAll, afterAll } from "bun:test"; -import { CommandCodeLanguageModel } from "../../src/model.js"; +import { CommandCodeLanguageModel } from "@/src/model.js"; import { mockFetchTrack, mockFetchError, mockFetchStream, makeCallOptions, -} from "../helpers/mocks.js"; +} from "@/tests/helpers/mocks.js"; const MODEL_ID = "test-model"; const API_KEY = "sk-test-key"; diff --git a/tests/unit/plugin-v2.test.ts b/tests/unit/plugin-v2.test.ts new file mode 100644 index 0000000..ba307f2 --- /dev/null +++ b/tests/unit/plugin-v2.test.ts @@ -0,0 +1,103 @@ +import { expect, test, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +let dual: { id: string; setup: (ctx: unknown) => Promise; server: () => Promise }; +let prevStateDir: string | undefined; +let testStateDir: string; + +beforeAll(async () => { + testStateDir = mkdtempSync(join(tmpdir(), "cc-v2-state-")); + prevStateDir = process.env.COMMANDCODE_PROVIDER_STATE_DIR; + process.env.COMMANDCODE_PROVIDER_STATE_DIR = testStateDir; + const mod = await import("@/plugin.ts"); + dual = mod.default; +}); + +afterAll(() => { + if (prevStateDir === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR; + else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prevStateDir; + rmSync(testStateDir, { recursive: true, force: true }); +}); + +test("default export carries V2 id/setup plus V1 server", async () => { + expect(dual.id).toBe("commandcode"); + expect(typeof dual.setup).toBe("function"); + expect(typeof dual.server).toBe("function"); + const mod = await import("@/plugin.ts"); + expect(mod.server).toBe(dual.server); +}); + +test("V2 setup adds provider with models when missing", async () => { + const calls: Array<{ name: string }> = []; + let added: any = null; + const ctx = { + provider: { + transform: async (fn: (editor: any) => void) => { + calls.push({ name: "provider.transform" }); + const editor = { + get: () => undefined, + add: (input: any) => { + added = input; + }, + update: () => { + throw new Error("update should not run when provider is missing"); + }, + models: { + set: () => { + throw new Error("models.set should not run on add path"); + }, + }, + }; + fn(editor); + }, + }, + }; + await dual.setup(ctx); + expect(calls.length).toBe(1); + expect(added.info.id).toBe("commandcode"); + expect(added.info.package).toBe("aisdk:@ai-sdk/openai-compatible"); + expect(added.info.settings.baseURL).toBe("https://api.commandcode.ai/provider/v1"); + expect(Array.isArray(added.models)).toBe(true); + expect(added.models.length).toBeGreaterThan(20); + const sample = added.models[0]; + expect(sample.providerID).toBe("commandcode"); + expect(sample.capabilities.input).toContain("text"); + expect(sample.cost[0].cache).toHaveProperty("read"); +}); + +test("V2 setup preserves existing package and sets models", async () => { + let updated: any = null; + let setModels: any = null; + const ctx = { + provider: { + transform: async (fn: (editor: any) => void) => { + const editor = { + get: () => ({ provider: { id: "commandcode" }, models: new Map() }), + add: () => { + throw new Error("add should not run when provider exists"); + }, + update: (_id: string, fn2: (p: any) => void) => { + updated = { id: _id }; + fn2(updated); + }, + models: { + set: (id: string, models: any) => { + setModels = { id, models }; + }, + }, + }; + fn(editor); + }, + }, + }; + await dual.setup(ctx); + expect(updated.id).toBe("commandcode"); + expect(updated.settings.baseURL).toBe("https://api.commandcode.ai/provider/v1"); + expect(updated.settings.apiKey).toBe("{env:COMMANDCODE_API_KEY}"); + // Must not overwrite an existing custom package. + expect(updated.package).toBeUndefined(); + expect(setModels.id).toBe("commandcode"); + expect(setModels.models.length).toBeGreaterThan(20); +}); diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index e98490c..fac0d21 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { writeCatalogCache, type ModelEntry } from "../../src/startup.ts"; +import { writeCatalogCache, type ModelEntry } from "@/src/startup.ts"; type PluginResult = { config: (config: Record) => Promise; @@ -22,7 +22,14 @@ type PluginResult = { }; }; -type PluginModule = { default: () => Promise }; +type PluginModule = { + server: () => Promise; + default: { + id: string; + setup: (ctx: unknown) => Promise; + server: () => Promise; + }; +}; let pluginFn: PluginModule["default"]; let testStateDir: string; @@ -56,8 +63,8 @@ beforeAll(async () => { testStateDir = mkdtempSync(join(tmpdir(), "cc-plugin-state-")); prevStateDir = process.env.COMMANDCODE_PROVIDER_STATE_DIR; process.env.COMMANDCODE_PROVIDER_STATE_DIR = testStateDir; - const mod = await import("../../plugin.ts"); - pluginFn = mod.default; + const mod = await import("@/plugin.ts"); + pluginFn = mod.server; }); afterAll(() => { diff --git a/tests/unit/publish-policy.test.ts b/tests/unit/publish-policy.test.ts index 5a669a6..4836c5c 100644 --- a/tests/unit/publish-policy.test.ts +++ b/tests/unit/publish-policy.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "bun:test"; -import { decideCatalogSync, decidePublish } from "../../src/publish-policy.ts"; +import { decideCatalogSync, decidePublish } from "@/src/publish-policy.ts"; describe("decideCatalogSync", () => { test("extracts when force is true even if versions match", () => { diff --git a/tests/unit/release-workflow.test.ts b/tests/unit/release-workflow.test.ts index aafa46d..d90ff60 100644 --- a/tests/unit/release-workflow.test.ts +++ b/tests/unit/release-workflow.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "os"; import { join } from "path"; -import { buildManifest, withPluginVersion } from "../../src/manifest.ts"; +import { buildManifest, withPluginVersion } from "@/src/manifest.ts"; const buildManifestForVersion = (pluginVersion: string) => buildManifest({ diff --git a/tests/unit/startup.test.ts b/tests/unit/startup.test.ts index 3c61b06..f4a8a37 100644 --- a/tests/unit/startup.test.ts +++ b/tests/unit/startup.test.ts @@ -8,7 +8,7 @@ import { writeCatalogCache, writeStartupSummary, type ModelEntry, -} from "../../src/startup.ts"; +} from "@/src/startup.ts"; const sample: ModelEntry[] = [ { diff --git a/tests/unit/stream.test.ts b/tests/unit/stream.test.ts index a3c4d28..2f7a041 100644 --- a/tests/unit/stream.test.ts +++ b/tests/unit/stream.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { parseStreamEvents } from "../../src/stream.js"; -import { sseEvent } from "../helpers/mocks.js"; +import { parseStreamEvents } from "@/src/stream.js"; +import { sseEvent } from "@/tests/helpers/mocks.js"; async function collectStream(stream: ReadableStream): Promise { const reader = stream.getReader(); diff --git a/tests/unit/sync-models.test.ts b/tests/unit/sync-models.test.ts index ea0cf18..515f591 100644 --- a/tests/unit/sync-models.test.ts +++ b/tests/unit/sync-models.test.ts @@ -2,9 +2,9 @@ import { expect, test, describe } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { parseAvailabilityIds } from "../../src/catalog.ts"; -import { buildSyncArtifacts } from "../../scripts/sync-models.ts"; -import { lastSuccessfulModelCount, meetsModelCountFloor } from "../../src/manifest.ts"; +import { parseAvailabilityIds } from "@/src/catalog.ts"; +import { buildSyncArtifacts } from "@/scripts/sync-models.ts"; +import { lastSuccessfulModelCount, meetsModelCountFloor } from "@/src/manifest.ts"; function availabilityResponse(ids: string[]): unknown { return { object: "list", data: ids.map((id) => ({ id })) }; diff --git a/tests/unit/v2models.test.ts b/tests/unit/v2models.test.ts new file mode 100644 index 0000000..df8ecba --- /dev/null +++ b/tests/unit/v2models.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { generateV2Models, toV2Model } from "@/src/v2models.ts"; +import { toConfigKey, type ModelEntry } from "@/src/catalog.ts"; + +const base: ModelEntry = { + id: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + tier: "premium", + reasoning: true, + reasoningEfforts: ["low", "high"], + tool_call: true, + cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + limit: { context: 200000, output: 16000 }, + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, +}; + +test("toV2Model maps V1 entry to V2 capabilities/cost/limit", () => { + const m = toV2Model(base); + expect(m.id).toBe(toConfigKey(base.id)); + expect(m.modelID).toBe(toConfigKey(base.id)); + expect(m.providerID).toBe("commandcode"); + expect(m.name).toBe(base.name); + expect(m.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] }); + expect(m.cost).toEqual([{ input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }]); + expect(m.limit).toEqual({ context: 200000, output: 16000 }); + expect(m.variants).toEqual([ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + ]); + expect(m.status).toBe("active"); + expect(m.enabled).toBe(true); +}); + +test("toV2Model defaults to text-only when no modality metadata", () => { + const m = toV2Model({ + ...base, + attachment: undefined, + modalities: undefined, + tool_call: false, + reasoningEfforts: undefined, + cost: { input: 1, output: 2 }, + }); + expect(m.capabilities).toEqual({ tools: false, input: ["text"], output: ["text"] }); + expect(m.cost[0].cache).toEqual({ read: 0, write: 0 }); + expect(m.variants).toEqual([]); +}); + +test("toV2Model derives image input from attachment flag", () => { + const m = toV2Model({ ...base, modalities: undefined, attachment: true }); + expect(m.capabilities.input).toEqual(["text", "image"]); +}); + +test("generateV2Models maps every entry", () => { + const out = generateV2Models([base, { ...base, id: "openai/gpt-5.5" }]); + expect(out.length).toBe(2); + expect(out.map((m) => m.id)).toEqual(["claude-sonnet-4-6", "gpt-5.5"]); +}); diff --git a/tsconfig.json b/tsconfig.json index 2099597..38a21aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,10 @@ "skipLibCheck": true, "types": ["node"], "outDir": "dist", - "declaration": true + "declaration": true, + "paths": { + "@/*": ["./*"] + } }, "include": ["index.ts", "plugin.ts", "src/**/*.ts"], "exclude": ["scripts/", "tests/"]