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 0a0c94e..148e2e7 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/"]