From ad5f1e3bbf5a70f99ffc8568e74c48ee2aa1065d Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 26 Sep 2026 10:28:12 -0300 Subject: [PATCH 1/4] feat: zod validation, explicit load results, V2 wire modelID fix - Add src/schemas.ts (ModelEntry/Manifest/availability/plugin config) as the single source of truth; catalog/manifest types derive via z.infer. - Add explicit LoadResult boundaries (src/load-result.ts) and a hermetic EnvDeps seam (src/env.ts); degraded reasons carry the real cause. - Parse bundled models.json entry-by-entry, dropping invalid entries with reasons instead of failing the whole catalog. - V2 modelID is the full catalog wire id; the UI/map key stays the toConfigKey short name. --- bun.lock | 5 +- package.json | 3 + plugin.ts | 182 ++++++++++++++++++++---------- scripts/build-plugin.ts | 3 +- src/catalog.ts | 138 +++++++++++------------ src/env.ts | 25 +++++ src/load-result.ts | 22 ++++ src/manifest.ts | 28 +---- src/schemas.ts | 103 +++++++++++++++++ src/startup.ts | 40 ++++--- src/v2models.ts | 4 +- tests/unit/catalog.test.ts | 72 ++++++++++++ tests/unit/load-result.test.ts | 64 +++++++++++ tests/unit/plugin-v2.test.ts | 4 + tests/unit/plugin.test.ts | 16 +++ tests/unit/schemas.test.ts | 199 +++++++++++++++++++++++++++++++++ tests/unit/startup.test.ts | 69 ++++++++++-- tests/unit/v2models.test.ts | 10 +- 18 files changed, 804 insertions(+), 183 deletions(-) create mode 100644 src/env.ts create mode 100644 src/load-result.ts create mode 100644 src/schemas.ts create mode 100644 tests/unit/load-result.test.ts create mode 100644 tests/unit/schemas.test.ts diff --git a/bun.lock b/bun.lock index 739045a..f457e3c 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "commandcode-go-opencode-provider", + "dependencies": { + "zod": "4.6.5", + }, "devDependencies": { "@semantic-release/exec": "7.1.0", "@semantic-release/github": "12.0.9", @@ -619,7 +622,7 @@ "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], diff --git a/package.json b/package.json index 95f7986..e9fd5ae 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,9 @@ "sync:global": "bun run scripts/sync-models.ts --update-global", "catalog:ci": "bun run scripts/catalog-sync-ci.ts" }, + "dependencies": { + "zod": "4.6.5" + }, "devDependencies": { "@semantic-release/exec": "7.1.0", "@semantic-release/github": "12.0.9", diff --git a/plugin.ts b/plugin.ts index 2540971..970f5df 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,76 +1,106 @@ import { readFileSync, existsSync } from "fs"; -import { homedir } from "os"; import { join } from "path"; +import { z } from "zod"; import { generateOpencodeModels, - loadCatalogFromLocalCommandCode, + loadCatalogFromLocalCommandCodeResult, type ModelEntry, } from "./src/catalog.js"; -import { - readCatalogCache, - writeCatalogCache, - writeStartupSummary, - pluginStateDir, -} from "./src/startup.js"; +import { readCatalogCacheResult, writeCatalogCache, writeStartupSummary } from "./src/startup.js"; +import { liveEnv, resolveStateDir, type EnvDeps } from "./src/env.js"; +import { causeMessage, errResult, okResult, type LoadResult } from "./src/load-result.js"; import type { CatalogManifest } from "./src/manifest.js"; import { generateV2Models } from "./src/v2models.js"; import { catalogPaths } from "./src/paths.js"; +import { ManifestSchema, ModelEntrySchema, PluginFileConfigSchema } from "./src/schemas.js"; const { models: MODELS_PATH, manifest: MANIFEST_PATH, version: VERSION_PATH } = catalogPaths(); -interface PluginFileConfig { - disableModelSync?: boolean; - commandCodePackagePath?: string; - debugStartupLogs?: boolean; -} +type PluginFileConfig = z.infer; -function loadPluginConfig(): PluginFileConfig { - const dir = join(homedir(), ".config", "opencode"); +function loadPluginConfig(deps: EnvDeps = liveEnv): PluginFileConfig { + const dir = join(deps.homedir(), ".config", "opencode"); const configPath = [ join(dir, "opencode-commandcode.json"), join(dir, "commandcode-go-opencode-provider.json"), ].find((p) => existsSync(p)); - if (!configPath) return {}; + if (!configPath) return PluginFileConfigSchema.parse({}); + let raw: unknown = {}; try { - return JSON.parse(readFileSync(configPath, "utf-8")); + raw = JSON.parse(readFileSync(configPath, "utf-8")) as unknown; } catch { - return {}; + raw = {}; } + const parsed = PluginFileConfigSchema.safeParse(raw); + return parsed.success ? parsed.data : PluginFileConfigSchema.parse({}); } export type { ModelEntry }; -function loadBundledModels(): ModelEntry[] | null { +type BundledModels = { models: ModelEntry[]; dropped: number; dropReasons: string[] }; + +/** Explicit bundled read: parsed models plus the reason when unavailable. */ +function loadBundledModels(): LoadResult { + let raw: string; try { - return JSON.parse(readFileSync(MODELS_PATH, "utf-8")); - } catch { - return null; + raw = readFileSync(MODELS_PATH, "utf-8"); + } catch (error) { + return errResult(`bundled models.json unreadable: ${causeMessage(error)}`); } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch (error) { + return errResult(`bundled models.json invalid JSON: ${causeMessage(error)}`); + } + if (!Array.isArray(parsed)) return errResult("bundled models.json is not an array"); + const models: ModelEntry[] = []; + const dropReasons: string[] = []; + for (let index = 0; index < parsed.length; index++) { + const result = ModelEntrySchema.safeParse(parsed[index]); + if (result.success) models.push(result.data); + else if (dropReasons.length < 3) { + const detail = result.error.issues.map((issue) => issue.message).join("; "); + dropReasons.push(`entry ${index}: ${detail}`); + } + } + return okResult({ models, dropped: parsed.length - models.length, dropReasons }); } -function readBundledManifest(): CatalogManifest | null { - if (!existsSync(MANIFEST_PATH)) return null; +/** Explicit manifest read: `ok(null)` is a missing file (version falls back). */ +function readBundledManifest(): LoadResult { + if (!existsSync(MANIFEST_PATH)) return okResult(null); + let parsed: unknown; try { - return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as CatalogManifest; - } catch { - return null; + parsed = JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as unknown; + } catch (error) { + return errResult(`bundled manifest.json unreadable: ${causeMessage(error)}`); } + const result = ManifestSchema.safeParse(parsed); + if (!result.success) { + const detail = result.error.issues.map((issue) => issue.message).join("; "); + return errResult(`bundled manifest.json invalid: ${detail}`); + } + return okResult(result.data); } -function readBundledVersion(): string | null { +/** Explicit version read: manifest first, `_version.txt` fallback, else a reason. */ +function readBundledVersion(): LoadResult { const manifest = readBundledManifest(); - if (manifest?.commandCodeVersion) return manifest.commandCodeVersion; - if (!existsSync(VERSION_PATH)) return null; + if (manifest.ok && manifest.value?.commandCodeVersion) { + return okResult(manifest.value.commandCodeVersion); + } + if (!existsSync(VERSION_PATH)) return errResult("version file missing"); try { - const parts = readFileSync(VERSION_PATH, "utf-8").split("\n"); - const first = parts[0]?.trim(); - return first || null; - } catch { - return null; + const first = readFileSync(VERSION_PATH, "utf-8").split("\n")[0]?.trim(); + if (!first) return errResult("version file empty"); + return okResult(first); + } catch (error) { + return errResult(`version file unreadable: ${causeMessage(error)}`); } } -type CatalogLoad = { +export type CatalogLoad = { models: ModelEntry[]; catalogSource: "bundled" | "cache" | "opt-in-local"; commandCodeVersion: string | null; @@ -78,61 +108,93 @@ type CatalogLoad = { degradedReason: string | null; }; -function loadCatalogEntries(): CatalogLoad { - const pluginCfg = loadPluginConfig(); +export function loadCatalogEntries(deps: EnvDeps = liveEnv): CatalogLoad { + const pluginCfg = loadPluginConfig(deps); const override = - pluginCfg.commandCodePackagePath?.trim() || process.env.COMMANDCODE_PACKAGE_PATH?.trim() || ""; + pluginCfg.commandCodePackagePath?.trim() || + deps.getEnv("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; + const reasons: string[] = []; if (override) { - const localCatalog = loadCatalogFromLocalCommandCode({ packagePath: override }); - if (localCatalog && localCatalog.models.length > 0) { - models = localCatalog.models; + const local = loadCatalogFromLocalCommandCodeResult({ packagePath: override }, deps); + if (local.ok && local.value && local.value.models.length > 0) { + models = local.value.models; catalogSource = "opt-in-local"; - commandCodeVersion = localCatalog.version; + commandCodeVersion = local.value.version; + } else if (!local.ok) { + reasons.push(`local override failed (${local.reason})`); + } else { + reasons.push("local override produced no models"); } } if (models.length === 0) { const bundled = loadBundledModels(); - if (bundled) { - models = bundled; + if (bundled.ok) { + models = bundled.value.models; catalogSource = "bundled"; - commandCodeVersion = readBundledVersion(); + const version = readBundledVersion(); + commandCodeVersion = version.ok ? version.value : null; const manifest = readBundledManifest(); - if (manifest?.status === "degraded" || manifest?.status === "broken") { + if ( + manifest.ok && + manifest.value && + (manifest.value.status === "degraded" || manifest.value.status === "broken") + ) { degraded = true; - degradedReason = - manifest.status === "broken" + reasons.push( + manifest.value.status === "broken" ? "bundled catalog marked broken" - : "bundled catalog has models with no listed price"; + : "bundled catalog has models with no listed price", + ); + } + if (bundled.value.dropped > 0) { + degraded = true; + const details = + bundled.value.dropReasons.length > 0 ? `: ${bundled.value.dropReasons.join("; ")}` : ""; + reasons.push( + `dropped ${bundled.value.dropped} invalid bundled model ${bundled.value.dropped === 1 ? "entry" : "entries"}${details}`, + ); } } else { - const cached = readCatalogCache(); - if (cached) { - models = cached; + const cached = readCatalogCacheResult(undefined, deps); + if (cached.ok) { + models = cached.value; catalogSource = "cache"; degraded = true; - degradedReason = "bundled models.json unreadable; using last-good cache"; + reasons.push(`bundled models.json failed (${bundled.reason}); using last-good cache`); } else { degraded = true; - degradedReason = "no bundled catalog and no cache"; + reasons.push(`no bundled catalog (${bundled.reason}) and no cache (${cached.reason})`); } } } - return { models, catalogSource, commandCodeVersion, degraded, degradedReason }; + // Override notes only sharpen an already-degraded report; a healthy + // bundled load stays non-degraded exactly as before. + return { + models, + catalogSource, + commandCodeVersion, + degraded, + degradedReason: degraded ? reasons.join("; ") : null, + }; } -function persistCatalogSideEffects(load: CatalogLoad, debug: boolean): void { +function persistCatalogSideEffects( + load: CatalogLoad, + debug: boolean, + deps: EnvDeps = liveEnv, +): void { if (load.models.length > 0) { try { - writeCatalogCache(pluginStateDir(), load.models); + writeCatalogCache(resolveStateDir(deps), load.models); } catch { // ignore cache write } @@ -147,7 +209,7 @@ function persistCatalogSideEffects(load: CatalogLoad, debug: boolean): void { degradedReason: load.degradedReason, }; try { - writeStartupSummary(pluginStateDir(), summary); + writeStartupSummary(resolveStateDir(deps), summary); } catch { // ignore } diff --git a/scripts/build-plugin.ts b/scripts/build-plugin.ts index 97b0940..418cc44 100644 --- a/scripts/build-plugin.ts +++ b/scripts/build-plugin.ts @@ -2,7 +2,8 @@ // 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). +// via --target node. Runtime imports (currently zod) are bundled into +// dist/plugin.js — keep them minimal, dist size is release-noted (see AGENTS.md). import { spawnSync } from "node:child_process"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; diff --git a/src/catalog.ts b/src/catalog.ts index 91ab88f..8e1182a 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -2,22 +2,14 @@ import { createRequire } from "module"; import { execSync } from "child_process"; import { existsSync, readFileSync, statSync } from "fs"; import { dirname, join, resolve as resolvePath } from "path"; -import { fileURLToPath } from "url"; +import { z } from "zod"; +import { AvailabilityPayloadSchema, ModelEntrySchema } from "./schemas.js"; +import { liveEnv, type EnvDeps } from "./env.js"; +import { causeMessage, errResult, okResult, type LoadResult } from "./load-result.js"; export const NPM_PACKAGE = "command-code"; -export interface ModelEntry { - id: string; - name: string; - tier: "premium" | "open-source"; - reasoning: boolean; - reasoningEfforts?: string[]; - tool_call: boolean; - cost: { input: number; output: number; cache_read?: number; cache_write?: number }; - limit: { context: number; output: number }; - attachment?: boolean; - modalities?: { input: string[]; output: string[] }; -} +export type ModelEntry = z.infer; export interface CostEntry { id: string; @@ -259,18 +251,6 @@ export function extractBestProviderEnum( return fallback; } -export function extractWt(source: string): Record { - const best = extractBestProviderEnum(source); - if (!best) throw new Error(`Anchor not found: ANTHROPIC:"anthropic"`); - return best.value; -} - -export function getWtVarName(source: string): string { - const best = extractBestProviderEnum(source); - if (!best) throw new Error("Could not find Wt enum"); - return best.name; -} - /** Collect nearby `name="value"` bindings and simple aliases (`tI=Qx`). */ export function extractStringBindings( source: string, @@ -701,17 +681,20 @@ function tryNpmRootGlobal(): ResolvedCommandCodePackage | null { } } -function tryWindowsAppData(): ResolvedCommandCodePackage | null { - const appData = process.env.APPDATA; +function tryWindowsAppData(deps: EnvDeps = liveEnv): ResolvedCommandCodePackage | null { + const appData = deps.getEnv("APPDATA"); if (!appData) return null; return asPackageRoot(join(appData, "npm", "node_modules", NPM_PACKAGE)); } -export function resolveCommandCodePackage(options?: { - packagePath?: string; -}): ResolvedCommandCodePackage | null { +export function resolveCommandCodePackage( + options?: { + packagePath?: string; + }, + deps: EnvDeps = liveEnv, +): ResolvedCommandCodePackage | null { const explicit = - options?.packagePath?.trim() || process.env.COMMANDCODE_PACKAGE_PATH?.trim() || ""; + options?.packagePath?.trim() || deps.getEnv("COMMANDCODE_PACKAGE_PATH")?.trim() || ""; if (explicit) { const found = asPackageRoot(explicit); @@ -722,26 +705,60 @@ export function resolveCommandCodePackage(options?: { tryRequireResolve() || walkNodeModules(process.cwd()) || tryNpmRootGlobal() || - tryWindowsAppData() || + tryWindowsAppData(deps) || null ); } +/** + * Explicit local-catalog load: `ok(null)` means no package was found to try + * (opt-in unused or undiscoverable — not a failure), `err` carries the + * reason an attempted load failed, `ok(result)` is a usable catalog. + */ +export function loadCatalogFromLocalCommandCodeResult( + options?: { + packagePath?: string; + }, + deps: EnvDeps = liveEnv, +): LoadResult { + try { + const resolved = resolveCommandCodePackage(options, deps); + if (!resolved) return okResult(null); + let source: string; + try { + source = readFileSync(resolved.bundlePath, "utf-8"); + } catch (error) { + return errResult( + `local command-code bundle unreadable (${resolved.bundlePath}): ${causeMessage(error)}`, + ); + } + let models: ModelEntry[]; + try { + models = loadCatalogFromBundle(source); + } catch (error) { + return errResult(`local command-code catalog failed to evaluate: ${causeMessage(error)}`); + } + if (models.length === 0) return errResult("local command-code catalog empty"); + return okResult({ + models, + version: resolved.version, + root: resolved.root, + bundleSource: source, + }); + } catch (error) { + return errResult(`local command-code load failed: ${causeMessage(error)}`); + } +} + +/** Compat wrapper: value-or-null for callers that only need fail-open. */ export function loadCatalogFromLocalCommandCode(options?: { packagePath?: string; }): LocalCatalogResult | null { - try { - const resolved = resolveCommandCodePackage(options); - if (!resolved) return null; - const source = readFileSync(resolved.bundlePath, "utf-8"); - const models = loadCatalogFromBundle(source); - if (models.length === 0) return null; - return { models, version: resolved.version, root: resolved.root, bundleSource: source }; - } catch { - return null; - } + const result = loadCatalogFromLocalCommandCodeResult(options); + return result.ok ? result.value : null; } +/** Short OpenCode map / UI key. Wire id stays the catalog `entry.id` (vendor/…). */ export function toConfigKey(id: string): string { const slashIdx = id.indexOf("/"); const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id; @@ -751,6 +768,7 @@ export function toConfigKey(id: string): string { export function generateOpencodeModels(entries: ModelEntry[]): Record { const models: Record = {}; for (const entry of entries) { + // Map key = short UI id; `id` on the model = Command Code wire id (same split as V2 modelID). const key = toConfigKey(entry.id); const costObj: Record = { input: entry.cost.input, output: entry.cost.output }; if (entry.cost.cache_read !== undefined) costObj.cache_read = entry.cost.cache_read; @@ -788,36 +806,14 @@ export interface FilteredCatalog { unavailable: string[]; } -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - /** Validate the OpenAI-style `{ object: "list", data: [{ id }] }` availability payload. */ export function parseAvailabilityIds(payload: unknown): string[] { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - throw new Error("availability response must be an object"); - } - const body = payload as Record; - if (body["object"] !== "list") throw new Error('availability response object must be "list"'); - const data = body["data"]; - if (!Array.isArray(data) || data.length === 0) { - throw new Error("availability response data must be a non-empty array"); + const parsed = AvailabilityPayloadSchema.safeParse(payload); + if (!parsed.success) { + const detail = parsed.error.issues.map((issue) => issue.message).join("; "); + throw new Error(`invalid availability response: ${detail}`); } - const ids: string[] = []; - const seen = new Set(); - for (const item of data) { - if (!item || typeof item !== "object" || Array.isArray(item)) { - throw new Error("availability response items must be objects with a non-empty string id"); - } - const id = (item as Record)["id"]; - if (!isNonEmptyString(id)) { - throw new Error("availability response items must be objects with a non-empty string id"); - } - if (seen.has(id)) throw new Error(`duplicate availability id: ${id}`); - seen.add(id); - ids.push(id); - } - return ids; + return parsed.data.data.map((item) => item.id); } /** Retain only candidates whose exact ID is callable; collect sorted excluded IDs. */ @@ -833,7 +829,3 @@ export function filterCatalogByAvailability( .sort(); return { retained, unavailable }; } - -export function catalogModuleDir(): string { - return dirname(fileURLToPath(import.meta.url)); -} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..ddcaba3 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,25 @@ +import { homedir as osHomedir } from "os"; +import { join } from "path"; + +/** + * Hermetic seam for environment access. Runtime code takes an optional + * `deps` parameter defaulting to `liveEnv`, so existing call sites behave + * identically while tests inject fakes instead of juggling `process.env`. + * Filesystem calls stay direct; only environment reads go through here. + */ +export type EnvDeps = { + homedir: () => string; + getEnv: (key: string) => string | undefined; +}; + +export const liveEnv: EnvDeps = { + homedir: () => osHomedir(), + getEnv: (key) => process.env[key], +}; + +/** Plugin state directory: explicit override wins, otherwise under home. */ +export function resolveStateDir(deps: EnvDeps = liveEnv): string { + const override = deps.getEnv("COMMANDCODE_PROVIDER_STATE_DIR")?.trim(); + if (override) return override; + return join(deps.homedir(), ".local/state/opencode/commandcode-provider"); +} diff --git a/src/load-result.ts b/src/load-result.ts new file mode 100644 index 0000000..5b28a0f --- /dev/null +++ b/src/load-result.ts @@ -0,0 +1,22 @@ +/** + * Explicit load result: a value on success, a human-readable reason when + * there is none. Load boundaries return this instead of catching everything + * and yielding `null`, so callers can thread the reason into degraded + * reporting instead of guessing which read failed. + */ +export type LoadResult = { ok: true; value: T } | { ok: false; reason: string }; + +export function okResult(value: T): LoadResult { + return { ok: true, value }; +} + +export function errResult(reason: string): LoadResult { + return { ok: false, reason }; +} + +/** One-line summary of a caught value for embedding in a reason string. */ +export function causeMessage(cause: unknown): string { + if (cause instanceof Error && cause.message) return cause.message; + if (typeof cause === "string" && cause) return cause; + return "unknown error"; +} diff --git a/src/manifest.ts b/src/manifest.ts index a1d6296..8c72d97 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,4 +1,6 @@ import { writeFileSync } from "fs"; +import { z } from "zod"; +import { ManifestSchema } from "./schemas.js"; export type CatalogStatus = "healthy" | "degraded" | "broken"; export type CostCatalogBest = "cli" | "docs" | "thirdParty" | "free" | "fallback" | "missing"; @@ -24,23 +26,7 @@ export type CatalogReview = { unavailable?: UnavailableEntry[]; }; -export type CatalogManifest = { - schemaVersion: 1; - generatedAt: string; - pluginVersion: string; - commandCodeVersion: string; - commandCodeTarball: string; - modelCount: number; - reasoningModelCount: number; - extraction: { - modelCatalog: "ok" | "failed"; - costCatalog: CostCatalogBest; - costCatalogError: string | null; - }; - costSources: CostSources; - review?: CatalogReview; - status: CatalogStatus; -}; +export type CatalogManifest = z.infer; export type BuildManifestInput = { pluginVersion: string; @@ -82,7 +68,6 @@ export function bumpPatch(version: string): string { export function meetsModelCountFloor(modelCount: number, lastSuccessful: number | null): boolean { const floor = Math.max(20, Math.floor((lastSuccessful ?? 20) * 0.5)); - // ponytail: when there is no prior catalog, lastSuccessful is null and the floor is 20 return modelCount >= (lastSuccessful === null ? 20 : floor); } @@ -163,10 +148,3 @@ export function countCostSources(input: { } return sources; } - -export function bumpPackageVersionField(pkgJson: string): { json: string; version: string } { - const pkg = JSON.parse(pkgJson) as { version: string }; - const version = bumpPatch(pkg.version); - const json = pkgJson.replace(/("version"\s*:\s*")([^"]+)(")/, `$1${version}$3`); - return { json, version }; -} diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..93f00b2 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +/** Canonical catalog model entry. Single source of truth — src/catalog.ts derives its ModelEntry type from here via z.infer; never redeclare the shape there. */ +export const ModelEntrySchema = z.strictObject({ + id: z.string().min(1), + name: z.string().min(1), + tier: z.enum(["premium", "open-source"]), + reasoning: z.boolean(), + reasoningEfforts: z.array(z.string()).optional(), + tool_call: z.boolean(), + cost: z.strictObject({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }), + limit: z.strictObject({ + context: z.number().int(), + output: z.number().int(), + }), + attachment: z.boolean().optional(), + modalities: z + .strictObject({ + input: z.array(z.string()), + output: z.array(z.string()), + }) + .optional(), +}); + +/** Canonical catalog manifest. Single source of truth — src/manifest.ts derives its CatalogManifest type from here via z.infer; never redeclare the shape there. */ +export const ManifestSchema = z.strictObject({ + schemaVersion: z.literal(1), + generatedAt: z.string(), + pluginVersion: z.string(), + commandCodeVersion: z.string(), + commandCodeTarball: z.string(), + modelCount: z.number().int(), + reasoningModelCount: z.number().int(), + extraction: z.strictObject({ + modelCatalog: z.enum(["ok", "failed"]), + costCatalog: z.enum(["cli", "docs", "thirdParty", "free", "fallback", "missing"]), + costCatalogError: z.string().nullable(), + }), + costSources: z.strictObject({ + cli: z.number().int(), + officialDocs: z.number().int(), + thirdParty: z.number().int(), + free: z.number().int(), + fallback: z.number().int(), + unmatched: z.number().int(), + }), + review: z + .strictObject({ + thirdParty: z.array(z.string()), + free: z.array(z.string()), + unmatched: z.array(z.string()), + unavailable: z + .array( + z.strictObject({ + id: z.string(), + reason: z.literal("not-listed-by-provider-api"), + }), + ) + .optional(), + }) + .optional(), + status: z.enum(["healthy", "degraded", "broken"]), +}); + +/** + * OpenAI-style `{ object: "list", data: [{ id }] }` availability payload. + * Items stay lenient (the provider API returns extra per-model fields); + * duplicate ids are rejected. + */ +export const AvailabilityPayloadSchema = z + .object({ + object: z.literal("list"), + data: z.array(z.object({ id: z.string().min(1) })).min(1), + }) + .superRefine((payload, ctx) => { + const seen = new Set(); + for (const item of payload.data) { + if (seen.has(item.id)) { + ctx.addIssue({ + code: "custom", + message: `duplicate availability id: ${item.id}`, + }); + return; + } + seen.add(item.id); + } + }); + +/** + * Plugin file config (`~/.config/opencode/opencode-commandcode.json`). + * Deliberately lenient: user config files may carry extra keys, so unknown + * keys are stripped and every field falls back to its default. + */ +export const PluginFileConfigSchema = z.object({ + disableModelSync: z.boolean().optional().default(false), + commandCodePackagePath: z.string().optional().default(""), + debugStartupLogs: z.boolean().optional().default(false), +}); diff --git a/src/startup.ts b/src/startup.ts index a7a6d0a..075f296 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -1,7 +1,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { homedir } from "os"; import { join } from "path"; import type { ModelEntry } from "./catalog.js"; +import { liveEnv, resolveStateDir, type EnvDeps } from "./env.js"; +import { causeMessage, errResult, okResult, type LoadResult } from "./load-result.js"; export type { ModelEntry } from "./catalog.js"; @@ -14,22 +15,35 @@ export type StartupSummary = { degradedReason: string | null; }; -export function pluginStateDir(): string { - const override = process.env.COMMANDCODE_PROVIDER_STATE_DIR?.trim(); - if (override) return override; - return join(homedir(), ".local/state/opencode/commandcode-provider"); +export function pluginStateDir(deps: EnvDeps = liveEnv): string { + return resolveStateDir(deps); } -export function readCatalogCache(dir = pluginStateDir()): ModelEntry[] | null { - const path = join(dir, "catalog-cache.json"); - if (!existsSync(path)) return null; +/** + * Explicit cache read: hit yields the models, anything else yields the + * reason (missing file, empty/invalid content, unreadable JSON). + */ +export function readCatalogCacheResult( + dir?: string, + deps: EnvDeps = liveEnv, +): LoadResult { + const cacheDir = dir ?? resolveStateDir(deps); + const path = join(cacheDir, "catalog-cache.json"); + if (!existsSync(path)) return errResult("catalog cache missing"); + let parsed: unknown; try { - const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; - if (!Array.isArray(parsed) || parsed.length === 0) return null; - return parsed as ModelEntry[]; - } catch { - return null; + parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + } catch (error) { + return errResult(`catalog cache unreadable: ${causeMessage(error)}`); } + if (!Array.isArray(parsed) || parsed.length === 0) return errResult("catalog cache empty"); + return okResult(parsed as ModelEntry[]); +} + +/** Compat wrapper: explicit-dir callers that only need value-or-null. */ +export function readCatalogCache(dir?: string, deps: EnvDeps = liveEnv): ModelEntry[] | null { + const result = readCatalogCacheResult(dir, deps); + return result.ok ? result.value : null; } export function writeCatalogCache(dir: string, models: ModelEntry[]): void { diff --git a/src/v2models.ts b/src/v2models.ts index 419e799..f5ca65c 100644 --- a/src/v2models.ts +++ b/src/v2models.ts @@ -29,10 +29,12 @@ function v2Output(entry: ModelEntry): string[] { } export function toV2Model(entry: ModelEntry): V2Model { + // UI / OpenCode selection key stays short (commandcode/deepseek-v4.1-flash). + // modelID is the Command Code wire id — bare names 400 as unsupported_model. const id = toConfigKey(entry.id); return { id, - modelID: id, + modelID: entry.id, providerID: "commandcode", name: entry.name, capabilities: { diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 9ba96ad..03175f0 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -1,16 +1,21 @@ import { expect, test, describe } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { buildModelEntry, disambiguateModelNames, filterCatalogByAvailability, generateOpencodeModels, loadCatalogFromBundle, + loadCatalogFromLocalCommandCodeResult, parseAvailabilityIds, resolveCommandCodePackage, type CostEntry, type ModelEntry, type SnEntry, } from "@/src/catalog.ts"; +import type { EnvDeps } from "@/src/env.ts"; function costMapOf(entries: CostEntry[]): Map { const map = new Map(); @@ -197,6 +202,24 @@ describe("generateOpencodeModels", () => { }); }); + test("keeps short map key and full Command Code wire id", () => { + const models = generateOpencodeModels([ + { + id: "deepseek/deepseek-v4.1-flash", + name: "DeepSeek V4.1 Flash", + tier: "open-source", + reasoning: true, + tool_call: true, + cost: { input: 0.15, output: 0.6 }, + limit: { context: 1000000, output: 65536 }, + }, + ]); + expect(Object.keys(models)).toEqual(["deepseek-v4.1-flash"]); + const entry = models["deepseek-v4.1-flash"] as Record; + expect(entry.id).toBe("deepseek/deepseek-v4.1-flash"); + expect(entry.name).toBe("DeepSeek V4.1 Flash"); + }); + test("emits attachment and modalities, defaulting to text-only", () => { const models = generateOpencodeModels([ { @@ -358,6 +381,55 @@ describe("parseAvailabilityIds", () => { }); }); +describe("loadCatalogFromLocalCommandCodeResult", () => { + const noEnv: EnvDeps = { homedir: () => "/fake/home", getEnv: () => undefined }; + // Self-contained bundle fixture (mirrors the cost-failure shape above): + // two modelish entries so isModelCatalog accepts it. + const bundleSource = [ + '(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});', + 'var Aa="chatComplete",Ba="responses",qt=Vt[0];', + "var Sn=(Wt=>({", + 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",inputModalities:["text","image"],reasoning:!0,reasoningEfforts:["low"],contextWindow:2e5},', + 'GPT_X:{id:"gpt-5.5",provider:Wt.OPENAI,spec:Ba,label:"GPT",name:"GPT-5.5",description:"d",inputModalities:["text"],contextWindow:256000}', + "}))(Wt);", + ].join(""); + + test("ok with models for a fabricated package (explicit path, no env)", () => { + const root = mkdtempSync(join(tmpdir(), "cc-local-ok-")); + try { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "command-code", version: "9.9.9" }), + "utf-8", + ); + mkdirSync(join(root, "dist"), { recursive: true }); + // Bundle picker skips stubs under 4KB — pad the fixture past that. + writeFileSync(join(root, "dist", "cli.mjs"), `${bundleSource}\n${" ".repeat(5000)}`, "utf-8"); + const result = loadCatalogFromLocalCommandCodeResult({ packagePath: root }, noEnv); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.value?.version).toBe("9.9.9"); + expect(result.value?.models.length).toBeGreaterThan(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("err with a reason for an unevaluatable bundle file", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-local-bad-")); + try { + const file = join(dir, "bundle.mjs"); + writeFileSync(file, `not a bundle {{{${" ".repeat(5000)}`, "utf-8"); + const result = loadCatalogFromLocalCommandCodeResult({ packagePath: file }, noEnv); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.reason).toContain("local command-code"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("filterCatalogByAvailability", () => { const entry = (id: string): ModelEntry => ({ id, diff --git a/tests/unit/load-result.test.ts b/tests/unit/load-result.test.ts new file mode 100644 index 0000000..55af5ae --- /dev/null +++ b/tests/unit/load-result.test.ts @@ -0,0 +1,64 @@ +import { expect, test, describe } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { causeMessage, errResult, okResult, type LoadResult } from "@/src/load-result.ts"; +import { liveEnv, resolveStateDir, type EnvDeps } from "@/src/env.ts"; +import { pluginStateDir } from "@/src/startup.ts"; + +const fakeEnv = (overrides: Record = {}): EnvDeps => ({ + homedir: () => "/fake/home", + getEnv: (key) => overrides[key], +}); + +describe("LoadResult", () => { + test("ok carries a value and narrows", () => { + const result: LoadResult = okResult(3); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(3); + else throw new Error("unreachable"); + }); + + test("err carries a reason and narrows", () => { + const result: LoadResult = errResult("boom"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("boom"); + else throw new Error("unreachable"); + }); + + test("causeMessage unwraps errors and falls back", () => { + expect(causeMessage(new Error("nope"))).toBe("nope"); + expect(causeMessage("plain")).toBe("plain"); + expect(causeMessage(undefined)).toBe("unknown error"); + expect(causeMessage(null)).toBe("unknown error"); + }); +}); + +describe("EnvDeps", () => { + test("resolveStateDir defaults under the injected homedir", () => { + expect(resolveStateDir(fakeEnv())).toBe( + "/fake/home/.local/state/opencode/commandcode-provider", + ); + }); + + test("resolveStateDir honors and trims the injected override", () => { + expect(resolveStateDir(fakeEnv({ COMMANDCODE_PROVIDER_STATE_DIR: " /tmp/state " }))).toBe( + "/tmp/state", + ); + }); + + test("pluginStateDir delegates to the injected deps, ignoring process.env", () => { + expect(pluginStateDir(fakeEnv())).toBe("/fake/home/.local/state/opencode/commandcode-provider"); + const dir = mkdtempSync(join(tmpdir(), "cc-envseam-")); + try { + expect(pluginStateDir(fakeEnv({ COMMANDCODE_PROVIDER_STATE_DIR: dir }))).toBe(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("liveEnv reads the real environment", () => { + expect(liveEnv.homedir().length).toBeGreaterThan(0); + expect(liveEnv.getEnv("COMMANDCODE_PROVIDER_STATE_DIR_xyz_missing")).toBeUndefined(); + }); +}); diff --git a/tests/unit/plugin-v2.test.ts b/tests/unit/plugin-v2.test.ts index ba307f2..93e32a1 100644 --- a/tests/unit/plugin-v2.test.ts +++ b/tests/unit/plugin-v2.test.ts @@ -65,6 +65,10 @@ test("V2 setup adds provider with models when missing", async () => { expect(sample.providerID).toBe("commandcode"); expect(sample.capabilities.input).toContain("text"); expect(sample.cost[0].cache).toHaveProperty("read"); + const deepseek = added.models.find((m: { id: string }) => m.id === "deepseek-v4.1-flash"); + expect(deepseek).toBeDefined(); + expect(deepseek.modelID).toBe("deepseek/deepseek-v4.1-flash"); + expect(deepseek.id).toBe("deepseek-v4.1-flash"); }); test("V2 setup preserves existing package and sets models", async () => { diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index fac0d21..a804869 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -211,6 +211,22 @@ test("startup summary uses bundled manifest version and status", async () => { expect(summary.modelCount).toBeGreaterThan(20); }); +test("hermetic: loadCatalogEntries with injected env matches the live bundled load", async () => { + const mod = await import("@/plugin.ts"); + const load = mod.loadCatalogEntries({ + homedir: () => testStateDir, + getEnv: () => undefined, + }); + const manifest = JSON.parse(readFileSync(join(repoRoot, "manifest.json"), "utf-8")) as { + status: string; + commandCodeVersion: string; + }; + expect(load.catalogSource).toBe("bundled"); + expect(load.commandCodeVersion).toBe(manifest.commandCodeVersion); + expect(load.degraded).toBe(manifest.status === "degraded" || manifest.status === "broken"); + expect(load.models.length).toBeGreaterThan(20); +}); + test("config does not write to stdout or stderr by default", async () => { const logs: unknown[][] = []; const warns: unknown[][] = []; diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts new file mode 100644 index 0000000..5c027f0 --- /dev/null +++ b/tests/unit/schemas.test.ts @@ -0,0 +1,199 @@ +import { expect, test, describe } from "bun:test"; +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { parseAvailabilityIds } from "@/src/catalog.ts"; +import { + AvailabilityPayloadSchema, + ManifestSchema, + ModelEntrySchema, + PluginFileConfigSchema, +} from "@/src/schemas.ts"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +const minimalEntry = { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + tier: "premium", + reasoning: true, + tool_call: true, + cost: { input: 3, output: 15 }, + limit: { context: 200000, output: 16000 }, +}; + +const fullEntry = { + ...minimalEntry, + reasoningEfforts: ["low", "medium", "high"], + cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, +}; + +describe("ModelEntrySchema", () => { + test("accepts minimal and fully-populated entries", () => { + expect(ModelEntrySchema.safeParse(minimalEntry).success).toBe(true); + const full = ModelEntrySchema.safeParse(fullEntry); + expect(full.success).toBe(true); + expect(full.success && full.data).toEqual(fullEntry); + }); + + test("rejects unknown top-level keys (strict)", () => { + const parsed = ModelEntrySchema.safeParse({ ...minimalEntry, provider: "anthropic" }); + expect(parsed.success).toBe(false); + }); + + test("rejects unknown nested keys in cost, limit, and modalities (strict)", () => { + expect( + ModelEntrySchema.safeParse({ + ...minimalEntry, + cost: { input: 3, output: 15, cacheWrite5mCost: 3.75 }, + }).success, + ).toBe(false); + expect( + ModelEntrySchema.safeParse({ + ...minimalEntry, + limit: { context: 200000, output: 16000, extra: 1 }, + }).success, + ).toBe(false); + expect( + ModelEntrySchema.safeParse({ + ...minimalEntry, + modalities: { input: ["text"], output: ["text"], extra: [] }, + }).success, + ).toBe(false); + }); + + test("rejects wrong tier, empty id, and non-integer limits", () => { + expect(ModelEntrySchema.safeParse({ ...minimalEntry, tier: "free" }).success).toBe(false); + expect(ModelEntrySchema.safeParse({ ...minimalEntry, id: "" }).success).toBe(false); + const { id: _dropped, ...missingId } = minimalEntry; + expect(ModelEntrySchema.safeParse(missingId).success).toBe(false); + expect( + ModelEntrySchema.safeParse({ + ...minimalEntry, + limit: { context: 200000.5, output: 16000 }, + }).success, + ).toBe(false); + }); + + test("validates every bundled models.json entry with zero drops", () => { + const bundled = JSON.parse(readFileSync(join(repoRoot, "models.json"), "utf-8")) as unknown[]; + expect(bundled.length).toBeGreaterThan(20); + let dropped = 0; + for (const entry of bundled) { + if (!ModelEntrySchema.safeParse(entry).success) dropped++; + } + expect(dropped).toBe(0); + }); + + test("mixed array keeps valid entries and counts drops (fail-open degraded signal)", () => { + const mixed = [ + minimalEntry, + { ...minimalEntry, id: "extra-field-model", typoField: true }, + { ...minimalEntry, id: "bad-tier-model", tier: "ultra" }, + fullEntry, + ]; + const retained: unknown[] = []; + let dropped = 0; + for (const entry of mixed) { + const result = ModelEntrySchema.safeParse(entry); + if (result.success) retained.push(result.data); + else dropped++; + } + expect(retained).toHaveLength(2); + expect(dropped).toBe(2); + }); +}); + +describe("ManifestSchema", () => { + test("accepts the bundled manifest.json", () => { + const manifest = JSON.parse(readFileSync(join(repoRoot, "manifest.json"), "utf-8")) as unknown; + expect(ManifestSchema.safeParse(manifest).success).toBe(true); + }); + + test("rejects unknown keys, wrong schemaVersion, and bad status (strict)", () => { + const manifest = JSON.parse(readFileSync(join(repoRoot, "manifest.json"), "utf-8")) as Record< + string, + unknown + >; + expect(ManifestSchema.safeParse({ ...manifest, schemaVersion: 2 }).success).toBe(false); + expect(ManifestSchema.safeParse({ ...manifest, status: "unknown" }).success).toBe(false); + expect(ManifestSchema.safeParse({ ...manifest, unexpected: true }).success).toBe(false); + }); + + test("invalid manifest parses as missing (null path, no throw)", () => { + const readManifest = (raw: unknown) => { + const result = ManifestSchema.safeParse(raw); + return result.success ? result.data : null; + }; + expect(readManifest({ schemaVersion: 1 })).toBeNull(); + expect(readManifest(null)).toBeNull(); + expect(readManifest("not-json-shaped")).toBeNull(); + }); +}); + +describe("AvailabilityPayloadSchema / parseAvailabilityIds", () => { + const ok = (data: unknown[]) => ({ object: "list", data }); + + test("returns ids from a valid list response", () => { + expect(parseAvailabilityIds(ok([{ id: "a" }, { id: "b/c" }]))).toEqual(["a", "b/c"]); + }); + + test("tolerates extra provider fields on items and top level (lenient)", () => { + expect( + parseAvailabilityIds({ + object: "list", + data: [{ id: "a", object: "model", created: 1, owned_by: "x" }], + }), + ).toEqual(["a"]); + expect(AvailabilityPayloadSchema.safeParse(ok([{ id: "a" }])).success).toBe(true); + }); + + test("rejects empty data, blank ids, and non-object items", () => { + expect(() => parseAvailabilityIds(ok([]))).toThrow(); + expect(() => parseAvailabilityIds(ok([{ id: "" }]))).toThrow(); + expect(() => parseAvailabilityIds(ok([{ nope: 1 }]))).toThrow(); + expect(() => parseAvailabilityIds(null)).toThrow(); + expect(() => parseAvailabilityIds({ object: "other", data: [{ id: "a" }] })).toThrow(); + }); + + test("duplicate ids still error", () => { + expect(() => parseAvailabilityIds(ok([{ id: "a" }, { id: "a" }]))).toThrow(/duplicate/); + }); +}); + +describe("PluginFileConfigSchema", () => { + test("applies defaults for a missing/empty config", () => { + expect(PluginFileConfigSchema.parse({})).toEqual({ + disableModelSync: false, + commandCodePackagePath: "", + debugStartupLogs: false, + }); + }); + + test("keeps provided values and strips unknown keys (lenient)", () => { + expect( + PluginFileConfigSchema.parse({ + debugStartupLogs: true, + commandCodePackagePath: "/opt/command-code", + extraKey: "ignored", + }), + ).toEqual({ + disableModelSync: false, + commandCodePackagePath: "/opt/command-code", + debugStartupLogs: true, + }); + }); + + test("wrong-typed values fail validation so callers fall back to defaults", () => { + const raw = { debugStartupLogs: "yes" }; + const parsed = PluginFileConfigSchema.safeParse(raw); + const config = parsed.success ? parsed.data : PluginFileConfigSchema.parse({}); + expect(config).toEqual({ + disableModelSync: false, + commandCodePackagePath: "", + debugStartupLogs: false, + }); + }); +}); diff --git a/tests/unit/startup.test.ts b/tests/unit/startup.test.ts index f4a8a37..2665ef4 100644 --- a/tests/unit/startup.test.ts +++ b/tests/unit/startup.test.ts @@ -1,14 +1,16 @@ import { expect, test, describe } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { pluginStateDir, readCatalogCache, + readCatalogCacheResult, writeCatalogCache, writeStartupSummary, type ModelEntry, } from "@/src/startup.ts"; +import type { EnvDeps } from "@/src/env.ts"; const sample: ModelEntry[] = [ { @@ -22,23 +24,29 @@ const sample: ModelEntry[] = [ }, ]; +/** Injected env: no process.env reads, no homedir touches. */ +const fakeEnv = (overrides: Record = {}): EnvDeps => ({ + homedir: () => "/fake/home", + getEnv: (key) => overrides[key], +}); + describe("pluginStateDir", () => { - test("honors COMMANDCODE_PROVIDER_STATE_DIR when set", () => { + test("defaults under the injected homedir", () => { + expect(pluginStateDir(fakeEnv())).toBe("/fake/home/.local/state/opencode/commandcode-provider"); + }); + + test("honors the injected COMMANDCODE_PROVIDER_STATE_DIR", () => { const dir = mkdtempSync(join(tmpdir(), "cc-state-")); - const prev = process.env.COMMANDCODE_PROVIDER_STATE_DIR; - process.env.COMMANDCODE_PROVIDER_STATE_DIR = dir; try { - expect(pluginStateDir()).toBe(dir); + expect(pluginStateDir(fakeEnv({ COMMANDCODE_PROVIDER_STATE_DIR: dir }))).toBe(dir); } finally { - if (prev === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR; - else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prev; rmSync(dir, { recursive: true, force: true }); } }); }); describe("catalog cache", () => { - test("round-trips models and returns null for missing file", () => { + test("round-trips models and returns null for missing file (compat wrapper)", () => { const dir = mkdtempSync(join(tmpdir(), "cc-cache-")); try { expect(readCatalogCache(dir)).toBeNull(); @@ -48,6 +56,51 @@ describe("catalog cache", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("Result reports a missing cache with a reason", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-cache-miss-")); + try { + const result = readCatalogCacheResult(dir, fakeEnv()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("missing"); + else throw new Error("unreachable"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("Result reports a corrupt cache with a reason", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-cache-bad-")); + try { + writeFileSync(join(dir, "catalog-cache.json"), "{not json", "utf-8"); + const result = readCatalogCacheResult(dir, fakeEnv()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("unreadable"); + else throw new Error("unreachable"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("Result reports an empty cache with a reason", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-cache-empty-")); + try { + writeFileSync(join(dir, "catalog-cache.json"), "[]", "utf-8"); + const result = readCatalogCacheResult(dir, fakeEnv()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("empty"); + else throw new Error("unreachable"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("Result resolves the default dir from injected deps", () => { + // /fake/home does not exist: proves the default dir came from the fake, + // with no process.env involvement. + const result = readCatalogCacheResult(undefined, fakeEnv()); + expect(result.ok).toBe(false); + }); }); describe("startup summary", () => { diff --git a/tests/unit/v2models.test.ts b/tests/unit/v2models.test.ts index df8ecba..e5de66f 100644 --- a/tests/unit/v2models.test.ts +++ b/tests/unit/v2models.test.ts @@ -18,7 +18,7 @@ const base: ModelEntry = { 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.modelID).toBe(base.id); expect(m.providerID).toBe("commandcode"); expect(m.name).toBe(base.name); expect(m.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] }); @@ -32,6 +32,13 @@ test("toV2Model maps V1 entry to V2 capabilities/cost/limit", () => { expect(m.enabled).toBe(true); }); +test("toV2Model keeps short UI id and full Command Code wire modelID", () => { + const m = toV2Model({ ...base, id: "deepseek/deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash" }); + expect(m.id).toBe("deepseek-v4.1-flash"); + expect(m.modelID).toBe("deepseek/deepseek-v4.1-flash"); + expect(m.name).toBe("DeepSeek V4.1 Flash"); +}); + test("toV2Model defaults to text-only when no modality metadata", () => { const m = toV2Model({ ...base, @@ -55,4 +62,5 @@ 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"]); + expect(out.map((m) => m.modelID)).toEqual(["anthropic/claude-sonnet-4-6", "openai/gpt-5.5"]); }); From b482ef008d66c8a779209a14731c5764bb810a9e Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 26 Sep 2026 10:28:36 -0300 Subject: [PATCH 2/4] fix: use @ai-sdk/openai-compatible as V1 provider npm default V1 resolves provider npm through BunProc.install and calls the first create* export, so the plugin package itself can never be the SDK npm field. Default provider.commandcode to @ai-sdk/openai-compatible and add the Provider API baseURL unless a custom npm/baseURL is already configured. Aligns the sync:global helper and README transport section with the same defaults. --- README.md | 20 ++------------------ plugin.ts | 25 ++++++++++++++++++++----- scripts/sync-models.ts | 5 ++++- tests/unit/plugin.test.ts | 37 ++++++++++++++++++++++++++++++++++--- 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c63a8b1..3ff7057 100644 --- a/README.md +++ b/README.md @@ -37,25 +37,9 @@ Pin a version instead of `@latest` if you do not want automatic catalog patches. `file://` checkouts are **not** updated by npm; `git pull` after CI commits, or switch to the npm plugin line. -### 2. Provider transport (Command Code Provider API) +### 2. No provider block needed -This plugin supplies model metadata. Point OpenCode at Command Code's documented Provider API: - -```json -{ - "plugin": ["@brainervirus/opencode-commandcode@latest"], - "provider": { - "commandcode": { - "npm": "@ai-sdk/openai-compatible", - "name": "Command Code GOAT", - "env": ["COMMANDCODE_API_KEY"], - "options": { - "baseURL": "https://api.commandcode.ai/provider/v1" - } - } - } -} -``` +On OpenCode V2 the plugin registers the `commandcode` provider itself (Provider API base URL plus `COMMANDCODE_API_KEY` binding) and its models. On V1 the `server` hook fills the same `provider.commandcode` defaults — `npm: "@ai-sdk/openai-compatible"` plus the Provider API `baseURL`; the plugin package itself is never the SDK `npm` field. Only add a manual `provider.commandcode` entry if you need non-default transport options. ### 3. Connect diff --git a/plugin.ts b/plugin.ts index 970f5df..ed87cb8 100644 --- a/plugin.ts +++ b/plugin.ts @@ -16,6 +16,12 @@ import { ManifestSchema, ModelEntrySchema, PluginFileConfigSchema } from "./src/ const { models: MODELS_PATH, manifest: MANIFEST_PATH, version: VERSION_PATH } = catalogPaths(); +// Transport decision (docs/2026-08-28-ci-catalog/spec.md:20): OpenAI-compatible AI SDK + +// Provider API; this package is the plugin, never the SDK `npm` field. V1 takes the bare +// npm name; the `aisdk:` prefix is V2-only. +const PROVIDER_SDK_NPM = "@ai-sdk/openai-compatible"; +const PROVIDER_API_BASE_URL = "https://api.commandcode.ai/provider/v1"; + type PluginFileConfig = z.infer; function loadPluginConfig(deps: EnvDeps = liveEnv): PluginFileConfig { @@ -233,9 +239,19 @@ export async function server() { const pluginCfg = loadPluginConfig(); const debug = pluginCfg.debugStartupLogs === true; - if (!cc.npm) cc.npm = "commandcode-go-opencode-provider"; + if (!cc.npm) cc.npm = PROVIDER_SDK_NPM; if (!cc.name) cc.name = "Command Code"; if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"]; + // V1 resolves `npm` through BunProc.install and calls the first `create*` export, so + // the SDK package must be the OpenAI-compatible one; give it the Provider API URL + // unless the user already configured another baseURL (custom npm stays untouched). + if (cc.npm === PROVIDER_SDK_NPM) { + if (cc.options === undefined) cc.options = {}; + if (typeof cc.options === "object" && cc.options !== null) { + const options = cc.options as Record; + if (!options.baseURL) options.baseURL = PROVIDER_API_BASE_URL; + } + } if (cc.models) return; @@ -289,9 +305,9 @@ async function setup(ctx: any): Promise { id: "commandcode", name: "Command Code", activation: "enabled", - package: "aisdk:@ai-sdk/openai-compatible", + package: `aisdk:${PROVIDER_SDK_NPM}`, settings: { - baseURL: "https://api.commandcode.ai/provider/v1", + baseURL: PROVIDER_API_BASE_URL, apiKey: "{env:COMMANDCODE_API_KEY}", }, }, @@ -303,8 +319,7 @@ async function setup(ctx: any): Promise { 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.baseURL) provider.settings.baseURL = PROVIDER_API_BASE_URL; if (!provider.settings.apiKey) provider.settings.apiKey = "{env:COMMANDCODE_API_KEY}"; } }); diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index 44668ab..9c18d20 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -219,9 +219,12 @@ function updateGlobalConfig(modelsObj: Record) { if (!config.provider) config.provider = {}; if (!config.provider.commandcode) { config.provider.commandcode = { - npm: "commandcode-go-opencode-provider", + // V1 `npm` is an AI SDK package (not this plugin package) and the + // OpenAI-compatible SDK needs the Provider API base URL. + npm: "@ai-sdk/openai-compatible", name: "Command Code", env: ["COMMANDCODE_API_KEY"], + options: { baseURL: "https://api.commandcode.ai/provider/v1" }, }; } config.provider.commandcode.models = modelsObj; diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index a804869..5c0b009 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -153,9 +153,15 @@ test("config hook registers provider with npm and models", async () => { await plugin.config(config); const cc = (config.provider as Record>).commandcode; - expect(cc.npm).toBe("commandcode-go-opencode-provider"); + // V1 `npm` is an AI SDK package name (the plugin package is not the SDK; see + // docs/2026-08-28-ci-catalog/spec.md:20) and the OpenAI-compatible SDK needs the + // Provider API base URL to reach Command Code. + expect(cc.npm).toBe("@ai-sdk/openai-compatible"); expect(cc.name).toBe("Command Code"); expect(cc.env).toEqual(["COMMANDCODE_API_KEY"]); + expect((cc.options as Record).baseURL).toBe( + "https://api.commandcode.ai/provider/v1", + ); expect(cc.models).toBeDefined(); const models = cc.models as Record; expect(Object.keys(models).length).toBeGreaterThan(0); @@ -170,6 +176,25 @@ test("config hook does not overwrite existing npm field", async () => { const cc = (config.provider as Record>).commandcode; expect(cc.npm).toBe("custom-package"); + // Custom transports own their options; the default baseURL only applies to the + // OpenAI-compatible SDK path. + expect(cc.options).toBeUndefined(); +}); + +test("config hook does not overwrite existing options baseURL", async () => { + const plugin = await pluginFn(); + const config: Record = { + provider: { + commandcode: { + npm: "@ai-sdk/openai-compatible", + options: { baseURL: "https://proxy.example/v1" }, + }, + }, + }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + expect((cc.options as Record).baseURL).toBe("https://proxy.example/v1"); }); test("config hook does not overwrite existing models", async () => { @@ -192,7 +217,10 @@ test("config hook creates provider block if missing", async () => { expect(config.provider).toBeDefined(); const cc = (config.provider as Record>).commandcode; expect(cc).toBeDefined(); - expect(cc.npm).toBe("commandcode-go-opencode-provider"); + expect(cc.npm).toBe("@ai-sdk/openai-compatible"); + expect((cc.options as Record).baseURL).toBe( + "https://api.commandcode.ai/provider/v1", + ); }); test("startup summary uses bundled manifest version and status", async () => { @@ -285,9 +313,12 @@ test("CA-04: registers npm/env with empty models when bundled and cache miss", a await plugin.config(config); const cc = (config.provider as Record>).commandcode; - expect(cc.npm).toBe("commandcode-go-opencode-provider"); + expect(cc.npm).toBe("@ai-sdk/openai-compatible"); expect(cc.name).toBe("Command Code"); expect(cc.env).toEqual(["COMMANDCODE_API_KEY"]); + expect((cc.options as Record).baseURL).toBe( + "https://api.commandcode.ai/provider/v1", + ); expect(cc.models).toEqual({}); const summary = JSON.parse(readFileSync(join(emptyDir, "startup.json"), "utf-8")); From a87bd6c59e22c9676c2f97148d96cda0bf0b745d Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 26 Sep 2026 10:28:46 -0300 Subject: [PATCH 3/4] chore(deps): refresh and hard-pin dependencies Refresh devDependencies (ai 7.0.114, oxlint 1.85.0, oxfmt 0.70.0, typescript 6.0.3) and remove the last caret ranges so the lockfile is fully exact. chore commits are path-gated out of releases. --- bun.lock | 136 +++++++++++++++++++++++++++------------------------ package.json | 16 +++--- 2 files changed, 79 insertions(+), 73 deletions(-) diff --git a/bun.lock b/bun.lock index f457e3c..9f66320 100644 --- a/bun.lock +++ b/bun.lock @@ -9,16 +9,16 @@ }, "devDependencies": { "@semantic-release/exec": "7.1.0", - "@semantic-release/github": "12.0.9", - "@semantic-release/npm": "13.1.5", + "@semantic-release/github": "12.0.10", + "@semantic-release/npm": "13.2.0", "@semantic-release/release-notes-generator": "14.1.1", - "@types/bun": "1.4.0", - "@types/node": "^25.9.1", - "ai": "6.0.191", - "oxfmt": "0.63.0", - "oxlint": "1.78.0", + "@types/bun": "1.4.2", + "@types/node": "25.9.8", + "ai": "7.0.114", + "oxfmt": "0.70.0", + "oxlint": "1.85.0", "semantic-release": "25.0.9", - "typescript": "^6.0.3", + "typescript": "6.0.3", }, "peerDependencies": { "ai": ">=6.0.0", @@ -34,11 +34,11 @@ "@actions/io": ["@actions/io@3.0.2", "", {}, "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.120", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.92", "", { "dependencies": { "@ai-sdk/provider": "4.0.18", "@ai-sdk/provider-utils": "5.0.47", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-awX24+QedfcIDTAdwkCUNG08vPJjkQI9KZLGh4Ua+vNbgeaNFJre96OIbBefOD9XPHSVpdXjwqtWmo4Yh7oczw=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.18", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.47", "", { "dependencies": { "@ai-sdk/provider": "4.0.18", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NZQ1hMxY6Hd4ZiinjpU5HUOsyCHiTFkHw7AiM1EVN+Cd3kNTi47flZWNvFwGqnBojntB1+2eBRTeHRuVLtPhEg=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -56,7 +56,7 @@ "@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], - "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@15.0.0", "", { "dependencies": { "@octokit/types": "^17.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-lw9A9YL5s4VPj+VEx8uMxaxQm2YTgrPDkrLEuZuu18R8TK3oPcXF4K6t52nx6xn1RcogZC9i188sAr/4XYJaQQ=="], "@octokit/plugin-retry": ["@octokit/plugin-retry@8.1.1", "", { "dependencies": { "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=7" } }, "sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg=="], @@ -68,83 +68,81 @@ "@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.63.0", "", { "os": "android", "cpu": "arm" }, "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.63.0", "", { "os": "android", "cpu": "arm64" }, "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.63.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.63.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.63.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.63.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.63.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.63.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.63.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="], - - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], @@ -160,9 +158,9 @@ "@semantic-release/exec": ["@semantic-release/exec@7.1.0", "", { "dependencies": { "@semantic-release/error": "^4.0.0", "aggregate-error": "^3.0.0", "debug": "^4.0.0", "execa": "^9.0.0", "lodash-es": "^4.17.21", "parse-json": "^8.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ=="], - "@semantic-release/github": ["@semantic-release/github@12.0.9", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-ODIqb0V3QqndipryEEiaBxUQCFjvv7Oese5Dt4omMGa60YRNEW0Sx3K+zri0uac2Y6S9nOlMehciWIzvvRCTGQ=="], + "@semantic-release/github": ["@semantic-release/github@12.0.10", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^15.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-QskygvpX8hFx9UEbqJ+C46OIhaJjsysNQ8PYJPuRDc/ek5+dh8WYGD1ofUahCcGLAOmnnwZTcmCJCEXM8XxhRw=="], - "@semantic-release/npm": ["@semantic-release/npm@13.1.5", "", { "dependencies": { "@actions/core": "^3.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^9.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg=="], + "@semantic-release/npm": ["@semantic-release/npm@13.2.0", "", { "dependencies": { "@actions/core": "^3.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^9.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-Uz4JGafLoEGiheLKEs1orockCWP6liY9D2YnvKHYbqo9hCHAw9fuAHcU53q29SXVCtkvDhs/QVUg7Vvuyfp4Jg=="], "@semantic-release/release-notes-generator": ["@semantic-release/release-notes-generator@14.1.1", "", { "dependencies": { "conventional-changelog-angular": "^8.0.0", "conventional-changelog-writer": "^8.0.0", "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA=="], @@ -174,19 +172,21 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@25.9.8", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ai": ["ai@6.0.191", "", { "dependencies": { "@ai-sdk/gateway": "3.0.120", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ=="], + "ai": ["ai@7.0.114", "", { "dependencies": { "@ai-sdk/gateway": "4.0.92", "@ai-sdk/provider": "4.0.18", "@ai-sdk/provider-utils": "5.0.47" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jRMOw7tPMWVYomoHWY5xHBXPQDyKTY1Pn3BX0Rh39UOP6y9FYq6xCIB6khgIC/b9CRg4+IxadwIbie+Eu7yU3g=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], @@ -208,7 +208,7 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -434,9 +434,9 @@ "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], - "oxfmt": ["oxfmt@0.63.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.63.0", "@oxfmt/binding-android-arm64": "0.63.0", "@oxfmt/binding-darwin-arm64": "0.63.0", "@oxfmt/binding-darwin-x64": "0.63.0", "@oxfmt/binding-freebsd-x64": "0.63.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.63.0", "@oxfmt/binding-linux-arm-musleabihf": "0.63.0", "@oxfmt/binding-linux-arm64-gnu": "0.63.0", "@oxfmt/binding-linux-arm64-musl": "0.63.0", "@oxfmt/binding-linux-ppc64-gnu": "0.63.0", "@oxfmt/binding-linux-riscv64-gnu": "0.63.0", "@oxfmt/binding-linux-riscv64-musl": "0.63.0", "@oxfmt/binding-linux-s390x-gnu": "0.63.0", "@oxfmt/binding-linux-x64-gnu": "0.63.0", "@oxfmt/binding-linux-x64-musl": "0.63.0", "@oxfmt/binding-openharmony-arm64": "0.63.0", "@oxfmt/binding-win32-arm64-msvc": "0.63.0", "@oxfmt/binding-win32-ia32-msvc": "0.63.0", "@oxfmt/binding-win32-x64-msvc": "0.63.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw=="], + "oxfmt": ["oxfmt@0.70.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.70.0", "@oxfmt/binding-android-arm64": "0.70.0", "@oxfmt/binding-darwin-arm64": "0.70.0", "@oxfmt/binding-darwin-x64": "0.70.0", "@oxfmt/binding-freebsd-x64": "0.70.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", "@oxfmt/binding-linux-arm64-gnu": "0.70.0", "@oxfmt/binding-linux-arm64-musl": "0.70.0", "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-musl": "0.70.0", "@oxfmt/binding-linux-s390x-gnu": "0.70.0", "@oxfmt/binding-linux-x64-gnu": "0.70.0", "@oxfmt/binding-linux-x64-musl": "0.70.0", "@oxfmt/binding-openharmony-arm64": "0.70.0", "@oxfmt/binding-win32-arm64-msvc": "0.70.0", "@oxfmt/binding-win32-ia32-msvc": "0.70.0", "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw=="], - "oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="], + "oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], "p-each-series": ["p-each-series@3.0.0", "", {}, "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw=="], @@ -570,7 +570,7 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -626,8 +626,6 @@ "@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], - "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], "@semantic-release/github/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="], @@ -950,6 +948,10 @@ "read-pkg/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "semantic-release/@semantic-release/github": ["@semantic-release/github@12.0.9", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-ODIqb0V3QqndipryEEiaBxUQCFjvv7Oese5Dt4omMGa60YRNEW0Sx3K+zri0uac2Y6S9nOlMehciWIzvvRCTGQ=="], + + "semantic-release/@semantic-release/npm": ["@semantic-release/npm@13.1.5", "", { "dependencies": { "@actions/core": "^3.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^9.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg=="], + "semantic-release/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="], "semantic-release/read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="], @@ -968,8 +970,6 @@ "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - "@semantic-release/github/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], "@semantic-release/github/aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -1010,6 +1010,8 @@ "read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], + "semantic-release/@semantic-release/github/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + "semantic-release/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], "semantic-release/aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -1042,6 +1044,8 @@ "read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + "semantic-release/@semantic-release/github/@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "signale/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "cli-highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -1054,6 +1058,8 @@ "read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "semantic-release/@semantic-release/github/@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/package.json b/package.json index e9fd5ae..b5f4e49 100644 --- a/package.json +++ b/package.json @@ -78,16 +78,16 @@ }, "devDependencies": { "@semantic-release/exec": "7.1.0", - "@semantic-release/github": "12.0.9", - "@semantic-release/npm": "13.1.5", + "@semantic-release/github": "12.0.10", + "@semantic-release/npm": "13.2.0", "@semantic-release/release-notes-generator": "14.1.1", - "@types/bun": "1.4.0", - "@types/node": "^25.9.1", - "ai": "6.0.191", - "oxfmt": "0.63.0", - "oxlint": "1.78.0", + "@types/bun": "1.4.2", + "@types/node": "25.9.8", + "ai": "7.0.114", + "oxfmt": "0.70.0", + "oxlint": "1.85.0", "semantic-release": "25.0.9", - "typescript": "^6.0.3" + "typescript": "6.0.3" }, "peerDependencies": { "ai": ">=6.0.0" From d572eb2f0804640aabb622cd2c58a0d97b62da8c Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 26 Sep 2026 10:28:54 -0300 Subject: [PATCH 4/4] docs: rewrite README flow and prune docs to specs README now leads with how the pipeline works (extract/filter, cost merge, artifacts, V1+V2 injection, fallbacks) and the shipped plugin config file. Plans and task notes are removed; docs/specs describe what ships, including the new catalog-freshness spec. AGENTS.md gains the docs rule and the validation/runtime-weight boundaries. --- AGENTS.md | 12 + README.md | 47 +- docs/2026-08-28-auto-merge-sync-prs/plan.md | 59 -- docs/2026-08-28-auto-merge-sync-prs/spec.md | 2 +- docs/2026-08-28-catalog-modalities/plan.md | 313 -------- docs/2026-08-28-catalog-modalities/spec.md | 2 +- docs/2026-08-28-ci-catalog/plan.md | 100 --- docs/2026-08-28-ci-catalog/spec.md | 12 +- docs/2026-08-28-unblock-catalog/plan.md | 722 ------------------ docs/2026-08-28-unblock-catalog/sdd/flow.json | 45 -- .../sdd/progress.md | 4 - .../sdd/task-1-brief.md | 74 -- .../sdd/task-1-report.md | 74 -- .../sdd/task-2-brief.md | 10 - .../sdd/task-2-report.md | 100 --- .../sdd/task-3-brief.md | 12 - .../sdd/task-3-report.md | 97 --- docs/2026-08-28-unblock-catalog/spec.md | 26 +- .../specs/2026-08-19-stable-model-identity.md | 456 ++--------- .../specs/2026-08-28-ci-catalog-automation.md | 83 +- docs/specs/2026-09-20-catalog-freshness.md | 195 +++++ 21 files changed, 369 insertions(+), 2076 deletions(-) delete mode 100644 docs/2026-08-28-auto-merge-sync-prs/plan.md delete mode 100644 docs/2026-08-28-catalog-modalities/plan.md delete mode 100644 docs/2026-08-28-ci-catalog/plan.md delete mode 100644 docs/2026-08-28-unblock-catalog/plan.md delete mode 100644 docs/2026-08-28-unblock-catalog/sdd/flow.json delete mode 100644 docs/2026-08-28-unblock-catalog/sdd/progress.md delete mode 100644 docs/2026-08-28-unblock-catalog/sdd/task-1-brief.md delete mode 100644 docs/2026-08-28-unblock-catalog/sdd/task-1-report.md delete mode 100644 docs/2026-08-28-unblock-catalog/sdd/task-2-brief.md delete mode 100644 docs/2026-08-28-unblock-catalog/sdd/task-2-report.md delete mode 100644 docs/2026-08-28-unblock-catalog/sdd/task-3-brief.md delete mode 100644 docs/2026-08-28-unblock-catalog/sdd/task-3-report.md create mode 100644 docs/specs/2026-09-20-catalog-freshness.md diff --git a/AGENTS.md b/AGENTS.md index f9bc319..e113e85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,10 @@ On 2026-09-02 this repo published **42 accidental npm versions** (0.7.5→0.7.46 - The intended release path for catalog updates is the automation's `fix(catalog): sync command-code@X` commit (patch). Do not rename it to `chore(catalog)`. - `package.json` / `manifest.json` version fields are written by automation only; don't bump them in feature PRs. +## Docs rule — shipped behavior changes touch docs in the same branch + +Any branch that changes shipped behavior — `plugin.ts`, `index.ts`, `src/**`, or the sync pipeline in `scripts/sync-models.ts` — must also touch `README.md` or the matching spec under `docs/specs/` in the same branch. Plans and task notes are not committed; specs describe what ships. Verify with `git diff --name-only main...HEAD`: if it lists a behavior path, it must also list `README.md` or a `docs/specs/` file. + ## Catalog extraction (`src/catalog.ts`) — fragile by design - It slices balanced `{…}` spans around the anchor `SONNET_4_6:{id:"claude-sonnet-4-6"` and evals them with string bindings collected from the 12k chars before the anchor (`extractStringBindings`). @@ -33,3 +37,11 @@ On 2026-09-02 this repo published **42 accidental npm versions** (0.7.5→0.7.46 - `main` is protected (5 required checks). Never push to `main` — open a PR and let auto-merge handle it. - `workflow_dispatch` always runs a workflow from **`main`**, never a PR head. Dispatching does not test your branch. - Secrets: `NPMJS` (npm **Automation** token, mapped to `NPM_TOKEN`/`NODE_AUTH_TOKEN` — a login token fails with `EOTP`), `RELEASE_SYNC_TOKEN` (PAT; PRs opened with `GITHUB_TOKEN` do not trigger CI runs on their branch). + +## Runtime weight + validation boundaries (2026-09-26: dist/plugin.js ~708KB after zod) + +- `src/schemas.ts` is the single source of truth for `ModelEntry` / `CatalogManifest` shapes. `src/catalog.ts` and `src/manifest.ts` derive them via `z.infer` — never redeclare the shape. Verify: `grep -rn "interface ModelEntry\|type CatalogManifest = {" src` must be empty. +- zod lives only at validation boundaries (bundled `models.json` / `manifest.json` reads in `plugin.ts`, provider availability payloads, user config files). Hot paths (`src/convert.ts`, `src/stream.ts`, `src/model.ts`, the `generate*` model loops) stay zod-free. Verify: `grep -rn 'from "zod"' src/convert.ts src/stream.ts src/model.ts` must be empty. +- `dist/plugin.js` budget: ~708KB with zod bundled (was ~45KB type-only). A second runtime dependency requires either `--external` in `scripts/build-plugin.ts` or updating this budget line. Verify: `bun run build && du -h dist/plugin.js`. +- V1 (`generateOpencodeModels` in `src/catalog.ts`) and V2 (`toV2Model` in `src/v2models.ts`) cost/limit/modality mappings must stay in parity — change one, update the other plus `tests/unit/v2models.test.ts`. UI/map keys use `toConfigKey` in `src/catalog.ts` (do not duplicate it); the Command Code wire id is always catalog `entry.id` (V1 model `id`, V2 `modelID`) — bare short names 400 as unsupported_model. +- Entry points: `plugin.ts` owns all config-hook logic; `index.ts` re-exports + SDK factory; `src/entry.ts` is bundle glue for `scripts/build-plugin.ts` only. Do not add a fourth entry or duplicate the catalog-load path. diff --git a/README.md b/README.md index 3ff7057..78107d4 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,23 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi ### What this package adds - Bundled `models.json` is the default runtime catalog (no local CLI scrape). -- CLI cost extraction can fail (as on `command-code@1.38.x`) without dropping models. -- Official docs fill missing costs; remaining paid gaps use [models.dev](https://models.dev) as a reference. Command Code free SKUs stay `$0`. +- Dual OpenCode entry: V2 `setup` injects the provider plus models via transforms; V1 `server` fills `provider.commandcode` defaults plus models and registers API-key auth (auth stays V1-only). +- Validation at the boundaries via `src/schemas.ts` (zod): bundled `models.json` / `manifest.json` reads, provider availability payloads, and the plugin config file. +- CLI cost extraction can fail without dropping models; official docs fill missing costs, remaining paid gaps use [models.dev](https://models.dev) as a reference price at sync time. Command Code free SKUs stay `$0`. - Vision vs text-only comes from the Command Code CLI catalog (`inputModalities` on every SKU). [models.dev](https://models.dev) only adds extra inputs (video/audio/pdf) when it matches. - Reasoning effort **variants** on models that declare `reasoningEfforts`. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). +## How it works + +Each CI sync extracts the model catalog from the latest `command-code` npm bundle, merges costs, and commits versioned artifacts; at startup the plugin loads those artifacts and registers them with OpenCode — never the other way around. + +- **Extract + filter** — model entries (ids, names, reasoning, `inputModalities`, limits) are evaluated out of the minified CLI bundle (`src/catalog.ts`), then intersected with the callable IDs reported by the provider API. +- **Costs merge** — per model, first hit wins: CLI bundle costs → official Command Code docs → free SKUs (`$0`) → [models.dev](https://models.dev) reference prices → unmatched placeholder. Anything still unmatched marks the catalog `degraded`. This runs at sync time only; runtime never fetches prices. +- **Artifacts** — `models.json` (the catalog), `_version.txt` (upstream version), `manifest.json` (counts, per-source cost stats, `healthy`/`degraded`/`broken` status). +- **V1 injection + V2 transform** — V1 `server()` fills `provider.commandcode` defaults and the models map; V2 `setup()` adds/updates the provider inventory (baseURL, API key binding) and sets models through `ctx.provider.transform`. +- **Degraded/cache fallbacks** — a `degraded`/`broken` manifest sets the degraded flag with a reason; an unreadable bundled `models.json` falls back to the last-good cache; auth/connect still registers even with an empty catalog. + ## Quick Start ### 1. Install the plugin @@ -33,7 +44,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi } ``` -Pin a version instead of `@latest` if you do not want automatic catalog patches. +Pin a version instead of `@latest` if you do not want automatic catalog patches. The config key stays `plugin` in OpenCode V2 — there is no `plugins` key. `file://` checkouts are **not** updated by npm; `git pull` after CI commits, or switch to the npm plugin line. @@ -43,7 +54,7 @@ On OpenCode V2 the plugin registers the `commandcode` provider itself (Provider ### 3. Connect -Run `/connect` in opencode, search for **Command Code**, and enter your API key, or set `COMMANDCODE_API_KEY`. +Set `COMMANDCODE_API_KEY`, or on OpenCode V1 run `/connect`, search for **Command Code**, and enter your API key. V2 registers the provider with the env binding; the `/connect` API-key method is V1-only. ### 4. Select a model @@ -51,6 +62,18 @@ Run `/connect` in opencode, search for **Command Code**, and enter your API key, /models ``` +Catalog patches arrive as plugin updates: `@latest` refreshes in the background and takes effect on the next OpenCode restart. If a new model is missing after an announced sync, restart opencode once. + +## Plugin config file + +Optional, `~/.config/opencode/opencode-commandcode.json` (legacy fallback name `commandcode-go-opencode-provider.json` still loads). Unknown keys are ignored; every field has a default. + +| Key | Default | Effect | +|---|---|---| +| `commandCodePackagePath` | `""` | Maintainer override: extract the catalog from a local `command-code` checkout instead of the bundle. Same as env `COMMANDCODE_PACKAGE_PATH`. | +| `debugStartupLogs` | `false` | Also mirror the startup summary to stderr. Default is quiet (`startup.json` only). | +| `disableModelSync` | `false` | Accepted for forward compatibility; currently has no effect. | + ## Optional local CLI override Maintainers only. OpenCode will scrape a local `command-code` install when `COMMANDCODE_PACKAGE_PATH` or `commandCodePackagePath` in `~/.config/opencode/opencode-commandcode.json` is set. @@ -61,16 +84,24 @@ Maintainers only. OpenCode will scrape a local `command-code` install when `COMM git clone https://github.com/BrainerVirus/opencode-commandcode.git cd opencode-commandcode bun install -bun run check # oxlint + oxfmt + bun test + tsc (same stack as workit) +bun run check # oxlint + oxfmt --check + bun test tests/unit/ + tsc (the release gate) ``` ```bash -bun run sync -- --remote # refresh models.json + manifest.json from command-code@latest +bun run sync -- --remote # refresh models.json + manifest.json + _version.txt from command-code@latest +bun run build # bundle src/entry.ts to dist/plugin.js +bun test tests/unit/ # unit suite (also via bun run test) +bun run test:integration # live-endpoint tests, not part of the gate +bun run verify:release-candidate # dry-run pack; manifest and package versions must match +bun run generate-readme # reports catalog counts only; README is hand-edited +bun run catalog:ci # entry used by the catalog-sync workflow ``` -CI (`.github/workflows/catalog-sync.yml`) opens a PR every 6 hours when Command Code ships a new catalog. That PR, and the post-release `chore/manifest-sync-v*` PR, auto-merge after **check (test)**, **check (typecheck)**, **check (lint)**, **check (format)**, and **check (pack)** are green. `.github/workflows/release.yml` then runs **semantic-release** (npm publish + GitHub Release + tag). Do not push to `main`. +Entry points: `plugin.ts` owns all config-hook logic (dual default `{ id, setup }` plus `server`); `index.ts` re-exports the plugin plus the `createCommandCode` SDK factory; `src/entry.ts` is bundle glue for `scripts/build-plugin.ts` only — it produces `dist/plugin.js`. + +CI (`.github/workflows/catalog-sync.yml`) opens a `fix(catalog)` PR every 6 hours when Command Code ships a new catalog; if extraction fails it opens a `catalog-break` issue instead. The PR auto-merges after **check (test)**, **check (typecheck)**, **check (lint)**, **check (format)**, and **check (pack)** are green. `.github/workflows/release.yml` then runs **semantic-release** (npm publish + GitHub Release + tag). Do not push to `main`. -The GitHub Actions secret name is `NPMJS` (same as workit). It is mapped to both `NPM_TOKEN` and `NODE_AUTH_TOKEN`. Use an npm **Automation** token (bypasses 2FA). A login token from `~/.npmrc` fails CI with `EOTP`. Catalog PRs get a real CI run when `RELEASE_SYNC_TOKEN` (or `CATALOG_PUSH_TOKEN`) is a PAT; `GITHUB_TOKEN` can open the PR but GitHub will not start workflows from that event. +The GitHub Actions secret name is `NPMJS`. It is mapped to both `NPM_TOKEN` and `NODE_AUTH_TOKEN`. Use an npm **Automation** token (bypasses 2FA). A login token from `~/.npmrc` fails CI with `EOTP`. Catalog PRs get a real CI run when `RELEASE_SYNC_TOKEN` is a PAT; `GITHUB_TOKEN` can open the PR but GitHub will not start workflows from that event. ## License diff --git a/docs/2026-08-28-auto-merge-sync-prs/plan.md b/docs/2026-08-28-auto-merge-sync-prs/plan.md deleted file mode 100644 index 109103d..0000000 --- a/docs/2026-08-28-auto-merge-sync-prs/plan.md +++ /dev/null @@ -1,59 +0,0 @@ -# Auto-merge sync PRs Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Spec:** `docs/2026-08-28-auto-merge-sync-prs/spec.md` -**Branch:** `feature/2026-08-28-auto-merge-sync-prs` - -**Goal:** Queue GitHub auto-merge on bot catalog and post-release manifest PRs; fail the job if queueing fails. - -**Architecture:** Keep `gh pr merge --auto --squash --delete-branch`. Remove the swallow on the release job. Call the same command from `catalog-sync-ci.ts` after create and after updating an existing PR. Tests assert the workflow/script strings. Repo `allow_auto_merge` is enabled via GitHub API (already done). - -**Tech Stack:** GitHub Actions, `gh`, Bun tests (`tests/unit/release-workflow.test.ts`). - -## Global Constraints - -- PRs against `BrainerVirus/opencode-commandcode` base `main`. Squash + delete source branch. -- In-place `feature/2026-08-28-auto-merge-sync-prs` (no worktrees). -- Conventional commit `ci:` so path-gated semantic-release does not publish. -- Do not fold this package into workit. - ---- - -### Task 1: Fail loud on manifest auto-merge; queue catalog PRs - -**Files:** -- Modify: `.github/workflows/release.yml` -- Modify: `scripts/catalog-sync-ci.ts` -- Modify: `tests/unit/release-workflow.test.ts` -- Modify: `README.md` - -- [ ] **Step 1: Write the failing test** - -In `tests/unit/release-workflow.test.ts`, on the sync step `run` string: - -- still contains `gh pr merge --auto --squash --delete-branch` -- does **not** contain `auto-merge unavailable` or `|| echo` - -On `scripts/catalog-sync-ci.ts` source: contains `gh pr merge --auto --squash --delete-branch`. - -- [ ] **Step 2: Run the test and confirm it fails** - -```bash -bun test tests/unit/release-workflow.test.ts -``` - -- [ ] **Step 3: Implement** - -`release.yml`: drop `|| echo "auto-merge unavailable — merge the sync PR after CI is green"`. - -`catalog-sync-ci.ts`: after `gh pr create` and after logging an updated existing PR, run `gh pr merge --auto --squash --delete-branch` (stdio inherit, same cwd). Do not swallow errors. - -README Development: catalog and manifest-sync PRs auto-merge when the five checks are green. - -- [ ] **Step 4: Run tests and `bun run check`** - -```bash -bun test tests/unit/release-workflow.test.ts -bun run check -``` diff --git a/docs/2026-08-28-auto-merge-sync-prs/spec.md b/docs/2026-08-28-auto-merge-sync-prs/spec.md index 98857ae..202025b 100644 --- a/docs/2026-08-28-auto-merge-sync-prs/spec.md +++ b/docs/2026-08-28-auto-merge-sync-prs/spec.md @@ -1,6 +1,6 @@ # Auto-merge catalog and manifest sync PRs -Status: approved (2026-08-28) +Status: shipped (0.7.1, 2026-08-29) **Branch:** `feature/2026-08-28-auto-merge-sync-prs` ## Goal diff --git a/docs/2026-08-28-catalog-modalities/plan.md b/docs/2026-08-28-catalog-modalities/plan.md deleted file mode 100644 index a4f95e1..0000000 --- a/docs/2026-08-28-catalog-modalities/plan.md +++ /dev/null @@ -1,313 +0,0 @@ -# Catalog Modalities Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Spec:** `docs/2026-08-28-catalog-modalities/spec.md` -**Branch:** `feature/2026-08-28-catalog-modalities` - -**Goal:** Bundled catalog models expose OpenCode `attachment` and `modalities` copied from models.dev, with unmatched SKUs defaulting to text-only. - -**Architecture:** Extend the existing models.dev parser and matching index. A new `applyModelsDevModalities` runs on every catalog model (unlike the cost skip set). `generateOpencodeModels` always emits the fields. Sync uses one models.dev fetch for both costs and modalities. - -**Tech Stack:** TypeScript, Bun (`bun test tests/unit/`), existing `src/costs-models-dev.ts` + `src/catalog.ts`. - -## Global Constraints - -- No vision allowlist. No session-prompt injection. No overwriting `limit` / `reasoning` / `tool_call`. -- Runtime does not fetch models.dev. -- Tests: `bun test tests/unit/` from `/home/cristhofer-pincetti/Documents/projects/personal/opencode-commandcode-provider`. -- Plugin imports stay `.js`; tests import `.ts`. -- Do not fold this package into workit. PRs against `BrainerVirus/opencode-commandcode` base `main`. -- Implementation on `feature/2026-08-28-catalog-modalities` (in-place checkout, no worktrees). - ---- - -### Task 1: Parse and apply modalities from models.dev - -**Files:** -- Modify: `src/costs-models-dev.ts` -- Modify: `tests/fixtures/models-dev/api.subset.json` -- Test: `tests/unit/costs-models-dev.test.ts` - -**Interfaces:** -- Consumes: existing `parseModelsDev`, `findRow` / `indexRows` matching -- Produces: - - `TEXT_ONLY_MODALITIES = { input: ["text"], output: ["text"] }` - - `ModelsDevRow` gains optional `attachment?: boolean` and `modalities?: { input: string[]; output: string[] }` - - `applyModelsDevModalities(models: ModelEntry[], rows: ModelsDevRow[]): number` — returns how many models matched a models.dev row with explicit attachment/modalities; every model is assigned fields - -- [ ] **Step 1: Write the failing test** - -Add vision + text-only rows to `tests/fixtures/models-dev/api.subset.json`: - -```json -{ - "google": { - "models": { - "google/gemini-3.5-flash": { - "id": "google/gemini-3.5-flash", - "name": "Gemini 3.5 Flash", - "attachment": true, - "modalities": { - "input": ["text", "image", "video", "audio", "pdf"], - "output": ["text"] - }, - "cost": { "input": 1.5, "output": 9, "cache_read": 0.15 } - } - } - }, - "tencent": { - "models": { - "tencent/hy4-preview": { - "id": "tencent/hy4-preview", - "name": "Tencent Hy4 Preview", - "attachment": false, - "modalities": { "input": ["text"], "output": ["text"] }, - "cost": { "input": 0.834, "output": 2.501, "cache_read": 0.042 } - } - } - } -} -``` - -Keep the existing `zai`, `vercel`, and `qwen` objects. Qwen stays cost-only (no modalities in the fixture) so the apply function must default it to text-only. - -In `tests/unit/costs-models-dev.test.ts`, add: - -```ts -import { applyModelsDevModalities, TEXT_ONLY_MODALITIES } from "../../src/costs-models-dev.ts"; - -describe("applyModelsDevModalities", () => { - const rows = parseModelsDev( - readFileSync(join(import.meta.dir, "../fixtures/models-dev/api.subset.json"), "utf-8"), - ); - - test("copies vision modalities and defaults unmatched plus cost-only rows to text-only", () => { - const models = [ - model({ id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }), - model({ id: "tencent/hy4-preview", name: "Tencent Hy4 Preview" }), - model({ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }), - model({ id: "unknown/not-on-models-dev", name: "Unknown" }), - model({ - id: "inclusionai/ling-3.0-flash-free", - name: "Ling 3.0 Flash Free", - cost: { input: 0, output: 0 }, - }), - ]; - const n = applyModelsDevModalities(models, rows); - expect(n).toBe(2); - expect(models[0].attachment).toBe(true); - expect(models[0].modalities).toEqual({ - input: ["text", "image", "video", "audio", "pdf"], - output: ["text"], - }); - expect(models[1].attachment).toBe(false); - expect(models[1].modalities).toEqual(TEXT_ONLY_MODALITIES); - expect(models[2].attachment).toBe(false); - expect(models[2].modalities).toEqual(TEXT_ONLY_MODALITIES); - expect(models[3].attachment).toBe(false); - expect(models[3].modalities).toEqual(TEXT_ONLY_MODALITIES); - expect(models[4].attachment).toBe(false); - expect(models[4].modalities).toEqual(TEXT_ONLY_MODALITIES); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `bun test tests/unit/costs-models-dev.test.ts` - -Expected: FAIL — `applyModelsDevModalities` / `TEXT_ONLY_MODALITIES` not exported. - -- [ ] **Step 3: Write minimal implementation** - -In `src/costs-models-dev.ts`: - -```ts -export const TEXT_ONLY_MODALITIES = { input: ["text"], output: ["text"] } as const; - -export type ModelsDevRow = { - id: string; - name: string; - cost: { input: number; output: number; cache_read?: number; cache_write?: number }; - attachment?: boolean; - modalities?: { input: string[]; output: string[] }; -}; -``` - -Extend `ModelsDevModel` with optional `attachment?: boolean` and `modalities?: { input?: string[]; output?: string[] }`. - -In `parseModelsDev`, after building `cost`, copy: - -```ts -if (typeof model.attachment === "boolean") row.attachment = model.attachment; -const input = model.modalities?.input?.filter((x) => typeof x === "string"); -const output = model.modalities?.output?.filter((x) => typeof x === "string"); -if (input?.length || output?.length) { - row.modalities = { - input: input?.length ? input : ["text"], - output: output?.length ? output : ["text"], - }; -} -``` - -Add `applyModelsDevModalities` using the existing `indexRows` / `findRow`. For each model: if the row has `modalities` or `attachment !== undefined`, copy them (`attachment` defaults to `modalities.input.includes("image")` when omitted); otherwise assign `attachment: false` and `{ ...TEXT_ONLY_MODALITIES }` (spread into a mutable `{ input: string[]; output: string[] }`). Return the count of models that used an explicit models.dev attachment/modalities (not the default). - -Extend `ModelEntry` in `src/catalog.ts`: - -```ts -attachment?: boolean; -modalities?: { input: string[]; output: string[] }; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `bun test tests/unit/costs-models-dev.test.ts` - -Expected: PASS - -- [ ] **Step 5: Commit** (only if the user asked to commit) - -```bash -git add src/costs-models-dev.ts src/catalog.ts tests/unit/costs-models-dev.test.ts tests/fixtures/models-dev/api.subset.json -git commit -m "feat: copy vision and text-only modalities from models.dev" -``` - ---- - -### Task 2: Emit attachment and modalities on OpenCode models - -**Files:** -- Modify: `src/catalog.ts` (`generateOpencodeModels`) -- Test: `tests/unit/catalog.test.ts` - -**Interfaces:** -- Consumes: `ModelEntry.attachment` / `ModelEntry.modalities` from Task 1 -- Produces: each OpenCode model object includes `attachment: boolean` and `modalities: { input: string[]; output: string[] }`; missing fields emit text-only - -- [ ] **Step 1: Write the failing test** - -In `describe("generateOpencodeModels")` add: - -```ts -test("emits attachment and modalities, defaulting to text-only", () => { - const models = generateOpencodeModels([ - { - id: "google/gemini-3.5-flash", - name: "Gemini 3.5 Flash", - tier: "open-source", - reasoning: false, - tool_call: true, - cost: { input: 1.5, output: 9 }, - limit: { context: 1048576, output: 65536 }, - attachment: true, - modalities: { input: ["text", "image"], output: ["text"] }, - }, - { - id: "tencent/hy4-preview", - name: "Tencent Hy4 Preview", - tier: "open-source", - reasoning: true, - tool_call: true, - cost: { input: 0.834, output: 2.501 }, - limit: { context: 1048576, output: 64000 }, - }, - ]); - const gemini = models["gemini-3.5-flash"] as Record; - const hy4 = models["hy4-preview"] as Record; - expect(gemini.attachment).toBe(true); - expect(gemini.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); - expect(hy4.attachment).toBe(false); - expect(hy4.modalities).toEqual({ input: ["text"], output: ["text"] }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `bun test tests/unit/catalog.test.ts` - -Expected: FAIL — `attachment` / `modalities` undefined on emitted models. - -- [ ] **Step 3: Write minimal implementation** - -In `generateOpencodeModels`, after building `model`: - -```ts -model.attachment = entry.attachment ?? false; -model.modalities = entry.modalities ?? { input: ["text"], output: ["text"] }; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `bun test tests/unit/catalog.test.ts` - -Expected: PASS - -- [ ] **Step 5: Commit** (only if the user asked to commit) - -```bash -git add src/catalog.ts tests/unit/catalog.test.ts -git commit -m "feat: emit OpenCode attachment and modalities" -``` - ---- - -### Task 3: Wire sync, refresh catalog, document - -**Files:** -- Modify: `scripts/sync-models.ts` -- Modify: `README.md` (What this package adds) -- Modify: `CHANGELOG.md` via `workit_changelog_apply` -- Modify: `models.json` (from `bun run sync`) - -**Interfaces:** -- Consumes: `parseModelsDev`, `applyModelsDevCosts`, `applyModelsDevModalities` -- Produces: one models.dev parse shared by costs and modalities; if fetch throws, still `applyModelsDevModalities(entries, [])` so every written model is text-only tagged - -- [ ] **Step 1: Write the failing test** - -No new unit test for the script. Assert the sync wiring by reading `scripts/sync-models.ts` in the step 4 check: `applyModelsDevModalities` is called with the same parsed rows, and the catch path still applies empty rows. - -- [ ] **Step 2: Run existing tests (still pass; wiring not covered)** - -Run: `bun test tests/unit/` - -Expected: PASS (Task 1–2 tests). - -- [ ] **Step 3: Write minimal implementation** - -In `scripts/sync-models.ts`, import `applyModelsDevModalities`. After the models.dev try/catch, always apply modalities: - -```ts -let modelsDevRows: ReturnType = []; -try { - const modelsDevJson = await fetchModelsDevJson(); - modelsDevRows = parseModelsDev(modelsDevJson); - const filled = applyModelsDevCosts(entries, modelsDevRows, skipAfterFree, thirdPartyIds); - console.log(` Applied models.dev costs to ${filled} models`); -} catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.log(` models.dev fill skipped: ${message}`); -} -const modalityFilled = applyModelsDevModalities(entries, modelsDevRows); -console.log(` Applied models.dev modalities to ${modalityFilled} models`); -``` - -README, under “What this package adds”, add: vision vs text-only (`attachment` / `modalities`) is copied from models.dev during catalog sync; unmatched models are text-only. - -Changelog Unreleased / Added: catalog models include vision and text-only metadata from models.dev. - -Run: `bun run sync` from the repo root (needs network). Then `bun run check`. - -- [ ] **Step 4: Verify** - -- `bun run check` passes -- `models.json` contains `"attachment"` and `"modalities"` on `tencent/hy4-preview` with `attachment: false` and input `["text"]` -- A known vision id (e.g. `google/gemini-3.5-flash`) has `attachment: true` and `"image"` in `modalities.input` - -- [ ] **Step 5: Commit** (only if the user asked to commit) - -```bash -git add scripts/sync-models.ts README.md CHANGELOG.md models.json manifest.json -git commit -m "feat: sync OpenCode modalities from models.dev" -``` diff --git a/docs/2026-08-28-catalog-modalities/spec.md b/docs/2026-08-28-catalog-modalities/spec.md index 5eff6a5..6956b9c 100644 --- a/docs/2026-08-28-catalog-modalities/spec.md +++ b/docs/2026-08-28-catalog-modalities/spec.md @@ -1,6 +1,6 @@ # Spec: Catalog modalities from Command Code CLI -Status: approved (2026-08-28) +Status: shipped (0.7.0, 2026-08-28) **Branch:** `feature/2026-08-28-catalog-modalities` Parent: [docs/2026-08-28-ci-catalog/spec.md](../2026-08-28-ci-catalog/spec.md) diff --git a/docs/2026-08-28-ci-catalog/plan.md b/docs/2026-08-28-ci-catalog/plan.md deleted file mode 100644 index 5b98aa5..0000000 --- a/docs/2026-08-28-ci-catalog/plan.md +++ /dev/null @@ -1,100 +0,0 @@ -# CI Catalog + npm Publish Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Spec:** `docs/2026-08-28-ci-catalog/spec.md` -**Parent:** `docs/specs/2026-08-28-ci-catalog-automation.md` -**Branch:** `feature/2026-08-28-ci-catalog` - -**Goal:** Cron catalog sync, scoped npm publish, and GitHub tags/releases that follow workit’s token pattern so OpenCode can install `@brainervirus/commandcode-go-opencode-provider@latest`. - -**Architecture:** Pure policy/manifest modules with unit tests. Thin CI scripts commit generated catalog files, publish only unpublished versions, then tag. Workflow YAML is asserted in tests (NPMJS → NODE_AUTH_TOKEN, tag after publish). - -**Tech Stack:** TypeScript, Bun, GitHub Actions, npm. - -## Global Constraints - -- Secret name is `NPMJS`, mapped to `NPM_TOKEN` and `NODE_AUTH_TOKEN`. -- `publishConfig.access` is `public`. Package name is `@brainervirus/commandcode-go-opencode-provider`. -- Never tag or `gh release` before npm publish succeeds. -- Catalog workflow must not commit `src/`, tests, or workflow files. -- Tests: `bun test tests/unit/` from `/home/cristhofer-pincetti/Documents/projects/personal/opencode-commandcode-provider`. -- Plugin imports use `.js` suffix; tests import `.ts`. -- Do not fold this package into workflow-toolkit. -- Do not change the user’s hybrid Provider API transport. - ---- - -### Task 1: Manifest schema and publish policy - -**Files:** -- Create: `src/manifest.ts`, `src/publish-policy.ts` -- Test: `tests/unit/manifest.test.ts`, `tests/unit/publish-policy.test.ts` - -**Interfaces:** -- `buildManifest(...)` → `CatalogManifest` with `status` healthy/degraded/broken and `costSources` -- `bumpPatch("0.5.0")` → `"0.5.1"` -- `meetsModelCountFloor(count, lastSuccessful)` → `count >= max(20, floor(last * 0.5))`; last missing → 20 -- `decideCatalogSync({ force, latestCommandCodeVersion, bundledCommandCodeVersion, pluginVersion, publishedPluginVersions })` → `{ extract, publishRetry, exit }` -- `decidePublish({ pluginVersion, publishedPluginVersions, npmTokenSet })` → `"publish" | "skip-no-token" | "skip-already-published"` - -- [ ] **Step 1:** Write failing tests for status, floor, bump, skip-extract + publish-retry, skip when already published. -- [ ] **Step 2:** Run `bun test tests/unit/manifest.test.ts tests/unit/publish-policy.test.ts` and confirm they fail. -- [ ] **Step 3:** Implement the modules. -- [ ] **Step 4:** Re-run tests; they pass. -- [ ] **Step 5:** Commit. - ---- - -### Task 2: Sync writes manifest; CI scripts - -**Files:** -- Modify: `scripts/sync-models.ts`, `src/costs-docs.ts` (optional out-set of doc-filled ids) -- Create: `scripts/catalog-sync-ci.ts`, `scripts/publish-if-needed.ts`, `scripts/catalog-break.ts` -- Test: `tests/unit/catalog-break.test.ts`; extend costs-docs if return shape changes - -**Behavior:** -- Sync writes `models.json`, `_version.txt`, `manifest.json` (pluginVersion from `package.json`). -- CI extract path bumps patch only when command-code version or models.json content changes. -- Model extract throw → catalog-break title/body helpers; no bump, no publish. -- `publish-if-needed` is a no-op when version is already on npm or token missing. - -- [ ] **Step 1:** Write failing tests for catalog-break title/body and manifest writer round-trip. -- [ ] **Step 2:** Run tests; confirm fail. -- [ ] **Step 3:** Implement writer + scripts. -- [ ] **Step 4:** Re-run unit tests. -- [ ] **Step 5:** Commit. - ---- - -### Task 3: Package rename, workflows, README, startup manifest - -**Files:** -- Modify: `package.json`, `plugin.ts`, `README.md`, `CHANGELOG.md`, `opencode.json` -- Create: `.github/workflows/catalog-sync.yml`, `.github/workflows/release.yml`, `.github/workflows/test.yml`, `.github/ISSUE_TEMPLATE/catalog-break.yml` -- Test: `tests/unit/release-workflow.test.ts`; startup/plugin tests for degraded-from-manifest - -**Assertions:** -- Package name + `publishConfig.access: public` + repository URLs point at BrainerVirus. -- Both publish jobs set `NPM_TOKEN` and `NODE_AUTH_TOKEN` from `secrets.NPMJS`. -- `setup-node` has `registry-url: https://registry.npmjs.org`. -- Catalog cron `0 */6 * * *`; `workflow_dispatch` with `force`. -- README install string is `@brainervirus/commandcode-go-opencode-provider@latest` (plugin) with hybrid provider override documented. -- Plugin reads `manifest.json` for `commandCodeVersion` / `degraded`; no stdout. - -- [ ] **Step 1:** Write failing workflow + package.json tests. -- [ ] **Step 2:** Run tests; confirm fail. -- [ ] **Step 3:** Add workflows, rename package, update README/plugin. -- [ ] **Step 4:** `bun test tests/unit/` passes. -- [ ] **Step 5:** Commit. - ---- - -### Task 4: First npm publish and GitHub tag - -**Files:** none beyond version `0.5.0` already in package.json. - -- [ ] **Step 1:** `npm publish --dry-run` from repo root; tarball includes `models.json`, `plugin.ts`, `manifest.json`, `src/`; excludes tests/docs. -- [ ] **Step 2:** `npm publish --access public`. -- [ ] **Step 3:** After publish succeeds, `git tag v0.5.0` and `gh release create` on the commit that is on `main` (or the feature commit if publishing before merge). -- [ ] **Step 4:** Confirm `npm view @brainervirus/commandcode-go-opencode-provider version` is `0.5.0`. diff --git a/docs/2026-08-28-ci-catalog/spec.md b/docs/2026-08-28-ci-catalog/spec.md index 255ed04..a2ebfdc 100644 --- a/docs/2026-08-28-ci-catalog/spec.md +++ b/docs/2026-08-28-ci-catalog/spec.md @@ -1,24 +1,24 @@ # CI Catalog Sync + npm Publish -Status: approved (2026-08-28) +Status: shipped (0.5.0, 2026-08-28; package renamed to `@brainervirus/opencode-commandcode` in 0.6.0) **Branch:** `feature/2026-08-28-ci-catalog` Parent: [docs/specs/2026-08-28-ci-catalog-automation.md](../specs/2026-08-28-ci-catalog-automation.md) ## Goal -Watch `command-code` on npm every 6 hours, refresh the bundled catalog, publish `@brainervirus/commandcode-go-opencode-provider` patches, tag GitHub Releases only after npm succeeds, and open a `catalog-break` issue when model extraction cannot be auto-fixed. +Watch `command-code` on npm every 6 hours, refresh the bundled catalog, publish `@brainervirus/opencode-commandcode` patches, tag GitHub Releases only after npm succeeds, and open a `catalog-break` issue when model extraction cannot be auto-fixed. ## Locked (do not reopen) -- npm name: `@brainervirus/commandcode-go-opencode-provider` with `publishConfig.access: "public"`. +- npm name: `@brainervirus/opencode-commandcode` with `publishConfig.access: "public"` (renamed from `@brainervirus/commandcode-go-opencode-provider` in 0.6.0). - GitHub Actions secret name: `NPMJS` (same as workit). Map it to **both** `NPM_TOKEN` and `NODE_AUTH_TOKEN`. `setup-node` `registry-url` writes an `.npmrc` that only reads `NODE_AUTH_TOKEN`; `NPM_TOKEN` alone is not enough. - Tag + GitHub Release **after** successful `npm publish` only. Never tag a version that is not on the registry. -- Catalog auto-commit uses `GITHUB_TOKEN` (or `CATALOG_PUSH_TOKEN` if protection blocks it). Those commits do **not** trigger other workflows, so catalog-sync must publish in the **same job**. -- Human merges to `main` run a separate release job that publishes only if `package.json` version is unpublished (code fixes). +- Bot catalog sync commits generated files to `chore/catalog-sync`, opens a `fix(catalog)` PR, and queues auto-merge. The PR is opened with `RELEASE_SYNC_TOKEN` (PAT) so checks run; `GITHUB_TOKEN`-opened PRs would not start workflows. +- Human merges to `main` run semantic-release; releases are path-gated (`scripts/analyze-release-scope.ts`), so CI/docs/tests-only merges do not publish. - Cost-only CLI failure still ships (`degraded` only if unmatched placeholder costs remain). Model extract failure → no publish, `catalog-break` issue. - Runtime catalog stays bundled `models.json`. No GitHub fetch at OpenCode startup. - Hybrid OpenCode transport stays `@ai-sdk/openai-compatible` + Provider API; this package is the **plugin**, not the SDK `npm` field. ## First publish -Repo has no prior `@brainervirus/...` package. First version is `0.5.0`. Local `npm whoami` is `brainervirus`. After this lands on `main`, cron owns later catalog patches. +Repo had no prior `@brainervirus/...` package. First version was `0.5.0` as `@brainervirus/commandcode-go-opencode-provider`; the package was renamed to `@brainervirus/opencode-commandcode` at `0.6.0`. After this landed on `main`, cron owns later catalog patches. diff --git a/docs/2026-08-28-unblock-catalog/plan.md b/docs/2026-08-28-unblock-catalog/plan.md deleted file mode 100644 index b56d2c5..0000000 --- a/docs/2026-08-28-unblock-catalog/plan.md +++ /dev/null @@ -1,722 +0,0 @@ -# Unblock Command Code Catalog Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Spec:** `docs/2026-08-28-unblock-catalog/spec.md` -**Branch:** `feature/2026-08-28-unblock-catalog` - -**Goal:** OpenCode loads Command Code models from a current bundled catalog with no local `command-code` CLI, no startup stdout leak, and no hard-fail when CLI cost extraction breaks. - -**Architecture:** Split bundle load so models are required and costs are optional. Plugin `config` reads bundled `models.json` by default (opt-in local scrape). Sync fills costs from CLI then official docs, then commits `models.json`. Diagnostics go to a JSON file, not stdout. - -**Tech Stack:** TypeScript, Bun (`bun test`), existing `src/catalog.ts` extractors, `fetch` for docs/tarball. - -## Global Constraints - -- Never fail catalog load because costs are missing. -- No `console.log` / `console.warn` in the plugin startup path unless `debugStartupLogs` is true. -- Local `command-code` scrape only when `commandCodePackagePath` or `COMMANDCODE_PACKAGE_PATH` is set. -- Exact id then exact display name for cost matching; no fuzzy matching. -- Do not add GitHub Actions, npm rename, identity aliases, or third-party cost APIs in this plan. -- Tests: `bun test tests/unit/` from repo root `/home/cristhofer-pincetti/Documents/projects/personal/opencode-commandcode-provider`. -- Plugin imports stay as `./src/catalog.js` (existing pattern). Test imports stay as `../../src/catalog.ts`. -- Each task lands exactly one contiguous non-empty commit range (`base..head`). -- Implementation runs on `feature/2026-08-28-unblock-catalog` (create from base via branch setup; tree is currently dirty on `main`). - ---- - -### Task 1: Costless bundle catalog load - -**Files:** -- Modify: `src/catalog.ts` (`loadCatalogFromBundle`) -- Test: `tests/unit/catalog.test.ts` - -**Interfaces:** -- Consumes: existing `extractModelCatalog`, `extractCostData`, `buildCostMap`, `buildModelEntry` -- Produces: `loadCatalogFromBundle(source: string): ModelEntry[]` — returns models even when `extractCostData` throws; uses empty cost map then `FALLBACK_COSTS` / default `{ input: 0.5, output: 2 }` via `buildModelEntry` - -- [ ] **Step 1: Write the failing test** - -Add this test inside `describe("loadCatalogFromBundle")` in `tests/unit/catalog.test.ts` (keep the existing happy-path test): - -```ts - test("returns models when cost extraction fails", () => { - const source = [ - '(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});', - 'var Aa="chatComplete",Ba="responses",qt=Vt[0];', - 'var Sn=(Wt=>({', - 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",reasoning:!0,reasoningEfforts:["low","medium","high"],contextWindow:2e5}', - '}))(Wt);', - ].join("") - - const entries = loadCatalogFromBundle(source) - const sonnet = entries.find((e) => e.id === "claude-sonnet-4-6") - expect(sonnet).toBeDefined() - expect(sonnet!.reasoningEfforts).toEqual(["low", "medium", "high"]) - expect(sonnet!.cost).toEqual({ input: 0.5, output: 2 }) - }) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `bun test tests/unit/catalog.test.ts` - -Expected: FAIL — `loadCatalogFromBundle` throws `Could not find cost data anchor` or `Could not evaluate cost data map`. - -- [ ] **Step 3: Write minimal implementation** - -In `src/catalog.ts`, replace `loadCatalogFromBundle` with: - -```ts -export function loadCatalogFromBundle(source: string): ModelEntry[] { - const models = extractModelCatalog(source) - let costMap = new Map() - try { - costMap = buildCostMap(extractCostData(source)) - } catch { - // ponytail: CLI cost map is optional; buildModelEntry applies FALLBACK_COSTS / defaults - } - - const entries: ModelEntry[] = [] - for (const model of Object.values(models)) { - if (!model || typeof model !== "object" || typeof model.id !== "string") continue - const entry = buildModelEntry(model, costMap) - if (entry) entries.push(entry) - } - - for (const extra of HARDCODED_EXTRAS) { - if (!entries.some((e) => e.id === extra.id)) { - const entry = buildModelEntry(extra, costMap) - if (entry) entries.push(entry) - } - } - - return sortModelEntries(entries) -} -``` - -Do not change `extractCostData` (it may still throw). `loadCatalogFromLocalCommandCode` already calls `loadCatalogFromBundle` and will stop returning `null` on cost-only failure. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `bun test tests/unit/catalog.test.ts` - -Expected: PASS (existing costful fixture still extracts numeric costs; new fixture uses default cost). - -- [ ] **Step 5: Commit** - -```bash -git add src/catalog.ts tests/unit/catalog.test.ts -git commit -m "$(cat <<'EOF' -fix: load command-code models when cost extraction fails - -EOF -)" -``` - ---- - -### Task 2: Quiet bundled startup - -**Files:** -- Modify: `plugin.ts` -- Test: `tests/unit/plugin.test.ts` -- Create: `src/startup.ts` - -**Interfaces:** -- Consumes: `loadModels` path via bundled `models.json`; `loadCatalogFromLocalCommandCode` only when override is set; `generateOpencodeModels` -- Produces: - - `pluginStateDir(): string` → `join(homedir(), ".local/state/opencode/commandcode-provider")` - - `catalogCachePath(): string` → `join(pluginStateDir(), "catalog-cache.json")` - - `startupSummaryPath(): string` → `join(pluginStateDir(), "startup.json")` - - `readCatalogCache(): ModelEntry[] | null` - - `writeCatalogCache(models: ModelEntry[]): void` - - `writeStartupSummary(summary: StartupSummary): void` - - `type StartupSummary = { catalogSource: "bundled" | "cache" | "opt-in-local"; commandCodeVersion: string | null; modelCount: number; reasoningModelCount: number; degraded: boolean; degradedReason: string | null }` - - Plugin `config` never calls `console.log` / `console.warn` unless `debugStartupLogs === true` - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/unit/plugin.test.ts`: - -```ts -test("config does not write to stdout or stderr by default", async () => { - const logs: unknown[][] = [] - const warns: unknown[][] = [] - const origLog = console.log - const origWarn = console.warn - console.log = (...args: unknown[]) => { - logs.push(args) - } - console.warn = (...args: unknown[]) => { - warns.push(args) - } - try { - const plugin = await pluginFn() - const config: Record = { provider: { commandcode: {} } } - await plugin.config(config) - expect(logs).toEqual([]) - expect(warns).toEqual([]) - const cc = (config.provider as Record>).commandcode - expect(cc.models).toBeDefined() - expect(Object.keys(cc.models as object).length).toBeGreaterThan(0) - } finally { - console.log = origLog - console.warn = origWarn - } -}) -``` - -Add `tests/unit/startup.test.ts`: - -```ts -import { expect, test, describe } from "bun:test" -import { mkdtempSync, readFileSync, rmSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" -import { - readCatalogCache, - writeCatalogCache, - writeStartupSummary, - type ModelEntry, -} from "../../src/startup.ts" - -const sample: ModelEntry[] = [ - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - tier: "premium", - reasoning: true, - tool_call: true, - cost: { input: 3, output: 15 }, - limit: { context: 200000, output: 16000 }, - }, -] - -describe("catalog cache", () => { - test("round-trips models and returns null for missing file", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-cache-")) - try { - expect(readCatalogCache(dir)).toBeNull() - writeCatalogCache(dir, sample) - expect(readCatalogCache(dir)).toEqual(sample) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) - -describe("startup summary", () => { - test("writes startup.json", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-sum-")) - try { - writeStartupSummary(dir, { - catalogSource: "bundled", - commandCodeVersion: "1.38.0", - modelCount: 1, - reasoningModelCount: 1, - degraded: false, - degradedReason: null, - }) - const parsed = JSON.parse(readFileSync(join(dir, "startup.json"), "utf-8")) - expect(parsed.catalogSource).toBe("bundled") - expect(parsed.modelCount).toBe(1) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `bun test tests/unit/startup.test.ts tests/unit/plugin.test.ts` - -Expected: FAIL — `src/startup.ts` not found; plugin test FAIL if `console.log` still runs (or PASS for plugin if bundled path already silent — then the new plugin test still documents the contract). - -- [ ] **Step 3: Write `src/startup.ts`** - -```ts -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs" -import { homedir } from "os" -import { join } from "path" -import type { ModelEntry } from "./catalog.js" - -export type StartupSummary = { - catalogSource: "bundled" | "cache" | "opt-in-local" - commandCodeVersion: string | null - modelCount: number - reasoningModelCount: number - degraded: boolean - degradedReason: string | null -} - -export function pluginStateDir(): string { - return join(homedir(), ".local/state/opencode/commandcode-provider") -} - -export function readCatalogCache(dir = pluginStateDir()): ModelEntry[] | null { - const path = join(dir, "catalog-cache.json") - if (!existsSync(path)) return null - try { - const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown - if (!Array.isArray(parsed) || parsed.length === 0) return null - return parsed as ModelEntry[] - } catch { - return null - } -} - -export function writeCatalogCache(dir: string, models: ModelEntry[]): void { - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, "catalog-cache.json"), JSON.stringify(models) + "\n", "utf-8") -} - -export function writeStartupSummary(dir: string, summary: StartupSummary): void { - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, "startup.json"), JSON.stringify(summary) + "\n", "utf-8") -} -``` - -- [ ] **Step 4: Rewrite plugin config hook** - -Replace `plugin.ts` so that: - -1. Delete `checkModelVersion` entirely. -2. `loadModels` returns `ModelEntry[] | null` instead of throwing. -3. Default catalog = bundled; call `loadCatalogFromLocalCommandCode` only when `pluginCfg.commandCodePackagePath` or `process.env.COMMANDCODE_PACKAGE_PATH` is set. -4. If bundled read fails, use `readCatalogCache()`; if that fails, `models = []` and still set `cc.npm` / `cc.env` / `cc.models = {}`. -5. After a successful non-empty load, `writeCatalogCache(pluginStateDir(), models)`. -6. Always `writeStartupSummary(pluginStateDir(), ...)`. -7. `console.log` / `console.warn` only when `pluginCfg.debugStartupLogs === true`. -8. Do not call `fetchModelsFromApi` in this increment (optional API merge is out of scope here; it can hang startup for 5s). - -Keep auth block unchanged. - -`loadPluginConfig` interface: - -```ts -interface PluginFileConfig { - disableModelSync?: boolean - commandCodePackagePath?: string - debugStartupLogs?: boolean -} -``` - -Config hook body (replace the current `config` function): - -```ts - config: async (config: Record) => { - if (!(config as Record).provider) { - ;(config as Record).provider = { commandcode: {} } - } - const cc = ((config as Record).provider as Record>) - ?.commandcode as Record | undefined - if (!cc) return - - 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" - if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"] - - 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() - } 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)) - } - }, -``` - -Rename current `loadModels` to `loadBundledModels(): ModelEntry[] | null` (return `null` on throw). Add `readBundledVersion()` that reads `_version.txt` first line or `null`. - -Import `readCatalogCache`, `writeCatalogCache`, `writeStartupSummary`, `pluginStateDir` from `./src/startup.js`. - -- [ ] **Step 5: Run tests** - -Run: `bun test tests/unit/plugin.test.ts tests/unit/startup.test.ts tests/unit/catalog.test.ts` - -Expected: PASS. Existing plugin tests still see `cc.models` from bundled `models.json`. - -- [ ] **Step 6: Commit** - -```bash -git add plugin.ts src/startup.ts tests/unit/plugin.test.ts tests/unit/startup.test.ts -git commit -m "$(cat <<'EOF' -fix: load bundled catalog without leaking plugin logs into OpenCode - -EOF -)" -``` - ---- - -### Task 3: Official-docs costs and refresh bundled catalog - -**Files:** -- Create: `src/costs-docs.ts` -- Create: `tests/unit/costs-docs.test.ts` -- Create: `tests/fixtures/command-code/models-page.md` -- Modify: `scripts/sync-models.ts` -- Modify: `models.json` (generated) -- Modify: `_version.txt` (generated) - -**Interfaces:** -- Consumes: `ModelEntry` from `src/catalog.ts`; `loadCatalogFromBundle` (Task 1) -- Produces: - - `parseMoneyCell(cell: string): number | undefined` - - `parseModelsTable(markdown: string): DocCostRow[]` - - `applyDocCosts(models: ModelEntry[], rows: DocCostRow[]): { filled: number }` - - `type DocCostRow = { name: string; id?: string; input: number; output: number; cache_read?: number; cache_write?: number }` - - `sync-models.ts` after CLI extract: fetch `https://commandcode.ai/models`, apply docs costs for models still on default `{ input: 0.5, output: 2 }` **or** missing from CLI cost map. Simpler rule: apply docs cost when the row matches and CLI did not set a cost from `extractCostData`. Track CLI hits by calling `extractCostData` in try/catch in the sync script and passing ids that already have CLI costs. - -Sync fill rule (locked): -- If CLI cost map has the model id, keep CLI cost. -- Else if a docs row matches exact id (case-insensitive) or exact `name` (case-insensitive), apply docs cost. -- Else leave `buildModelEntry` fallback. - -- [ ] **Step 1: Write fixture + failing tests** - -Create `tests/fixtures/command-code/models-page.md`: - -```markdown -| Model | Context | Input | Output | Cache read | Cache write | -| --- | --- | --- | --- | --- | --- | -| Claude Sonnet 4.6 | 200K | $3.00 | $15.00 | $0.30 | $3.75 | -| Gemini 3.7 Flash-50% | 1M | $1.50$0.75 | $7.50$3.75 | $0.15$0.075 | — | -| Laguna S 2.1FREE | 256K | Free | Free | Free | — | -``` - -Create `tests/unit/costs-docs.test.ts`: - -```ts -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" - -describe("parseMoneyCell", () => { - test("reads billed amount when a deal shows two prices", () => { - expect(parseMoneyCell("$1.50$0.75")).toBe(0.75) - expect(parseMoneyCell("$3.00")).toBe(3) - expect(parseMoneyCell("Free")).toBe(0) - expect(parseMoneyCell("—")).toBeUndefined() - }) -}) - -describe("parseModelsTable", () => { - test("parses display names and billed rates", () => { - const md = readFileSync(join(import.meta.dir, "../fixtures/command-code/models-page.md"), "utf-8") - const rows = parseModelsTable(md) - expect(rows.find((r) => r.name === "Claude Sonnet 4.6")).toEqual({ - name: "Claude Sonnet 4.6", - input: 3, - output: 15, - cache_read: 0.3, - cache_write: 3.75, - }) - const gemini = rows.find((r) => r.name === "Gemini 3.7 Flash-50%") - expect(gemini?.input).toBe(0.75) - expect(gemini?.output).toBe(3.75) - expect(rows.find((r) => r.name === "Laguna S 2.1FREE")?.input).toBe(0) - }) -}) - -describe("applyDocCosts", () => { - test("fills by exact name and skips models that already have cli costs", () => { - const models: ModelEntry[] = [ - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - tier: "premium", - reasoning: true, - tool_call: true, - cost: { input: 0.5, output: 2 }, - limit: { context: 200000, output: 16000 }, - }, - { - id: "keep/cli", - name: "Keep Cli", - tier: "open-source", - reasoning: false, - tool_call: true, - cost: { input: 9, output: 9 }, - limit: { context: 1000, output: 1000 }, - }, - ] - const filled = applyDocCosts(models, parseModelsTable( - readFileSync(join(import.meta.dir, "../fixtures/command-code/models-page.md"), "utf-8"), - ), new Set(["keep/cli"])) - expect(filled).toBe(1) - expect(models[0].cost).toEqual({ input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }) - expect(models[1].cost).toEqual({ input: 9, output: 9 }) - }) -}) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `bun test tests/unit/costs-docs.test.ts` - -Expected: FAIL — `src/costs-docs.ts` not found. - -- [ ] **Step 3: Implement `src/costs-docs.ts`** - -```ts -import type { ModelEntry } from "./catalog.js" - -export type DocCostRow = { - name: string - id?: string - input: number - output: number - cache_read?: number - cache_write?: number -} - -export function parseMoneyCell(cell: string): number | undefined { - const trimmed = cell.trim() - if (!trimmed || trimmed === "—" || trimmed === "-") return undefined - if (/^free$/i.test(trimmed)) return 0 - const amounts = [...trimmed.matchAll(/\$([0-9]+(?:\.[0-9]+)?)/g)].map((m) => Number(m[1])) - if (amounts.length === 0) return undefined - return amounts[amounts.length - 1] -} - -export function parseModelsTable(markdown: string): DocCostRow[] { - const rows: DocCostRow[] = [] - for (const line of markdown.split("\n")) { - if (!line.includes("|")) continue - const cells = line.split("|").map((c) => c.trim()).filter((c, i, arr) => !(i === 0 && c === "") && !(i === arr.length - 1 && c === "")) - if (cells.length < 4) continue - if (/^model$/i.test(cells[0]) || /^---/.test(cells[0])) continue - const input = parseMoneyCell(cells[2] ?? "") - const output = parseMoneyCell(cells[3] ?? "") - if (input === undefined || output === undefined) continue - const row: DocCostRow = { name: cells[0], input, output } - const cacheRead = parseMoneyCell(cells[4] ?? "") - const cacheWrite = parseMoneyCell(cells[5] ?? "") - if (cacheRead !== undefined) row.cache_read = cacheRead - if (cacheWrite !== undefined) row.cache_write = cacheWrite - rows.push(row) - } - return rows -} - -export function applyDocCosts( - models: ModelEntry[], - rows: DocCostRow[], - cliIds: Set = new Set(), -): number { - const byName = new Map(rows.map((r) => [r.name.toLowerCase(), r])) - const byId = new Map(rows.filter((r) => r.id).map((r) => [r.id!.toLowerCase(), r])) - let filled = 0 - for (const model of models) { - if (cliIds.has(model.id)) continue - const row = byId.get(model.id.toLowerCase()) ?? byName.get(model.name.toLowerCase()) - if (!row) continue - model.cost = { input: row.input, output: row.output } - if (row.cache_read !== undefined) model.cost.cache_read = row.cache_read - if (row.cache_write !== undefined) model.cost.cache_write = row.cache_write - filled++ - } - return filled -} - -export async function fetchOfficialModelsMarkdown(): Promise { - const urls = [ - "https://commandcode.ai/models", - "https://commandcode.ai/docs/resources/pricing-limits", - ] - for (const url of urls) { - try { - const resp = await fetch(url) - if (!resp.ok) continue - const text = await resp.text() - if (parseModelsTable(text).length > 0) return text - } catch { - // try next - } - } - return "" -} -``` - -Note: live `/models` HTML may not match the markdown fixture. If `parseModelsTable(html)` returns 0 rows, add a second parser path that still uses `|` rows when present; do not scrape arbitrary DOM. If both live pages yield 0 rows, sync still writes models with CLI/fallback costs and does not fail. - -If the live page uses a different column order, adjust `parseModelsTable` **only after** saving a real snippet into `tests/fixtures/command-code/models-page.live.md` and extending tests. Do not guess column indexes without a fixture. - -- [ ] **Step 4: Run unit tests** - -Run: `bun test tests/unit/costs-docs.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Wire `scripts/sync-models.ts`** - -After `loadCatalogFromBundle(bundle.source)` (or local catalog), compute `cliIds`: - -```ts -import { extractCostData, buildCostMap } from "../src/catalog.js" -import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "../src/costs-docs.js" - -function cliCostIds(source: string): Set { - try { - return new Set(buildCostMap(extractCostData(source)).keys()) - } catch { - return new Set() - } -} -``` - -In `main`, after `entries` is assigned: - -```ts - const sourceForCosts = local ? readFileSync(local /* need bundle path */) : (await fetchLatestBundle already discarded source) -``` - -Restructure so both local and remote keep the bundle `source` string in scope: - -```ts - let bundleSource: string | null = null - const local = !forceRemote ? loadCatalogFromLocalCommandCode() : null - if (local) { - entries = local.models - version = local.version - const resolved = /* already inside loadCatalogFromLocalCommandCode — change it instead */ - } -``` - -Minimal change: extend `LocalCatalogResult` in `src/catalog.ts`: - -```ts -export interface LocalCatalogResult { - models: ModelEntry[] - version: string - root: string - bundleSource: string -} -``` - -Set `bundleSource: source` in `loadCatalogFromLocalCommandCode`. `fetchLatestBundle` already returns `source`. - -Then: - -```ts - const cliIds = bundleSource ? cliCostIds(bundleSource) : new Set() - const docsMd = await fetchOfficialModelsMarkdown() - if (docsMd) applyDocCosts(entries, parseModelsTable(docsMd), cliIds) -``` - -Keep writing `models.json` and `_version.txt` as today. CLI `console.log` in the sync script is allowed (not the OpenCode plugin). - -Default `forceRemote` stays as `--remote` flag; the one-shot refresh **must** be run with `--remote` so it does not depend on a local CLI. - -- [ ] **Step 6: Refresh committed catalog** - -Run from repo root: - -```bash -bun run sync --remote -``` - -Expected: prints a current `command-code` version (not `1.1.0`), writes `models.json` with tens of models, updates `_version.txt`. - -If network fails, stop and fix; do not commit the old 1.1.0 catalog as if it were refreshed. - -- [ ] **Step 7: Re-run unit tests + plugin test** - -Run: `bun test tests/unit/` - -Expected: PASS. Plugin test `withVariants.length > 0` still holds if the new `models.json` includes `reasoningEfforts`. - -If that plugin test fails because the live catalog uses a different shape, keep `reasoningEfforts` as extracted from the tarball (Task 1). Do not invent efforts. - -- [ ] **Step 8: Commit** - -```bash -git add src/costs-docs.ts src/catalog.ts scripts/sync-models.ts tests/unit/costs-docs.test.ts tests/fixtures/command-code/models-page.md models.json _version.txt -git commit -m "$(cat <<'EOF' -feat: fill catalog costs from official docs and refresh bundled models - -EOF -)" -``` - ---- - -## Spec coverage (this increment) - -| Requirement | Task | -|---|---| -| Costless `loadCatalogFromBundle` | 1 | -| No stdout leak; bundled default; opt-in local | 2 | -| Last-good cache; startup summary file | 2 | -| Auth still registers with empty models | 2 (`cc.models = {}`) | -| Official docs cost fill | 3 | -| Refresh `models.json` from latest tarball | 3 | -| CI workflow / npm publish / identity aliases | out of scope | diff --git a/docs/2026-08-28-unblock-catalog/sdd/flow.json b/docs/2026-08-28-unblock-catalog/sdd/flow.json deleted file mode 100644 index a66a70e..0000000 --- a/docs/2026-08-28-unblock-catalog/sdd/flow.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "slug": "2026-08-28-unblock-catalog", - "activated": true, - "spec": { - "path": "docs/2026-08-28-unblock-catalog/spec.md", - "status": "approved", - "evidence": { - "host": "cursor", - "attested": false, - "confirmation": "contract" - }, - "approved_digest": "5cc03a0fda29609f77c2f573cdd6175a5c5569980a9d7a56fa5430d9101672e1" - }, - "plan": { - "path": "docs/2026-08-28-unblock-catalog/plan.md", - "status": "approved", - "evidence": { - "host": "cursor", - "attested": false, - "confirmation": "contract" - }, - "approved_digest": "a3006dd0b26a4fe7328ee02a1d5c8b15f863ede6fc72cdb95a5f3d14b2fabbaf" - }, - "menu": { - "presented": true, - "chosen": "inline", - "evidence": { - "host": "cursor", - "attested": false, - "confirmation": "contract" - } - }, - "execution": { - "status": "completed", - "mode": "inline", - "evidence": { - "host": "cursor", - "attested": false, - "confirmation": "contract" - }, - "coordinator_session_id": null - }, - "handoff_destination": false, - "updated_at": 1787941395561 -} diff --git a/docs/2026-08-28-unblock-catalog/sdd/progress.md b/docs/2026-08-28-unblock-catalog/sdd/progress.md deleted file mode 100644 index ea94b3a..0000000 --- a/docs/2026-08-28-unblock-catalog/sdd/progress.md +++ /dev/null @@ -1,4 +0,0 @@ -Task 1: complete (commits abdfa3e..fd990b7, review clean). Fixture used 2 models so RED hits cost extract; CA-01 met. -Task 2: complete (commits fd990b7..433121c, review clean after fix for test state isolation + CA-04/CA-05). -Task 3: complete (commits 433121c..c26d6fc, CA-06 _version.txt=1.38.1, CA-07 docs fill). DONE_WITH_CONCERNS: CLI extract 0 ids; 5 fallback models; 3 pre-existing auth.json unit fails. -Task 3: complete (commits 433121c..c26d6fc, review clean). Catalog command-code@1.38.1; 60/65 models docs-cost filled. diff --git a/docs/2026-08-28-unblock-catalog/sdd/task-1-brief.md b/docs/2026-08-28-unblock-catalog/sdd/task-1-brief.md deleted file mode 100644 index 2811fbc..0000000 --- a/docs/2026-08-28-unblock-catalog/sdd/task-1-brief.md +++ /dev/null @@ -1,74 +0,0 @@ -# Task 1 brief - -### Task 1: Costless bundle catalog load - -Read and follow `docs/2026-08-28-unblock-catalog/plan.md` section `### Task 1` verbatim (test source, loadCatalogFromBundle implementation, commit message). - -Files: modify src/catalog.ts loadCatalogFromBundle; test tests/unit/catalog.test.ts. - -TDD: add test returns models when cost extraction fails; run bun test tests/unit/catalog.test.ts expect FAIL; wrap extractCostData in try/catch; run tests expect PASS; commit. - -Do not change extractCostData. Do not touch plugin.ts. Work in-place on feature/2026-08-28-unblock-catalog at /home/cristhofer-pincetti/Documents/projects/personal/opencode-commandcode-provider. No git worktrees. - -## Exact test (add inside describe("loadCatalogFromBundle")) - -```ts - test("returns models when cost extraction fails", () => { - const source = [ - '(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});', - 'var Aa="chatComplete",Ba="responses",qt=Vt[0];', - 'var Sn=(Wt=>({', - 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",reasoning:!0,reasoningEfforts:["low","medium","high"],contextWindow:2e5}', - '}))(Wt);', - ].join("") - - const entries = loadCatalogFromBundle(source) - const sonnet = entries.find((e) => e.id === "claude-sonnet-4-6") - expect(sonnet).toBeDefined() - expect(sonnet!.reasoningEfforts).toEqual(["low", "medium", "high"]) - expect(sonnet!.cost).toEqual({ input: 0.5, output: 2 }) - }) -``` - -## Exact implementation (replace loadCatalogFromBundle in src/catalog.ts) - -```ts -export function loadCatalogFromBundle(source: string): ModelEntry[] { - const models = extractModelCatalog(source) - let costMap = new Map() - try { - costMap = buildCostMap(extractCostData(source)) - } catch { - // ponytail: CLI cost map is optional; buildModelEntry applies FALLBACK_COSTS / defaults - } - - const entries: ModelEntry[] = [] - for (const model of Object.values(models)) { - if (!model || typeof model !== "object" || typeof model.id !== "string") continue - const entry = buildModelEntry(model, costMap) - if (entry) entries.push(entry) - } - - for (const extra of HARDCODED_EXTRAS) { - if (!entries.some((e) => e.id === extra.id)) { - const entry = buildModelEntry(extra, costMap) - if (entry) entries.push(entry) - } - } - - return sortModelEntries(entries) -} -``` - -Commit: - -```bash -git add src/catalog.ts tests/unit/catalog.test.ts -git commit -m "$(cat <<'EOF' -fix: load command-code models when cost extraction fails - -EOF -)" -``` - - diff --git a/docs/2026-08-28-unblock-catalog/sdd/task-1-report.md b/docs/2026-08-28-unblock-catalog/sdd/task-1-report.md deleted file mode 100644 index aa1fedf..0000000 --- a/docs/2026-08-28-unblock-catalog/sdd/task-1-report.md +++ /dev/null @@ -1,74 +0,0 @@ -# Task 1 Report: Costless bundle catalog load - -## Status - -DONE_WITH_CONCERNS - -## Summary - -Wrapped `extractCostData` in `loadCatalogFromBundle` with try/catch so cost extraction failures no longer abort catalog loading. Models receive default costs via `buildModelEntry` (`FALLBACK_COSTS` or `{ input: 0.5, output: 2 }`). - -## TDD Evidence - -### RED - -1. Added test `returns models when cost extraction fails`. -2. First run with the brief's exact 1-model fixture **before** implementation: - -``` -(fail) loadCatalogFromBundle > returns models when cost extraction fails -error: Could not evaluate model catalog - at extractModelCatalog (src/catalog.ts:406) - at loadCatalogFromBundle (src/catalog.ts:542) -``` - -Root cause: `isModelCatalog` requires ≥2 model entries; the brief's 1-model fixture never reaches cost extraction. - -3. With a 2-model fixture (happy-path shape, no cost block) **before** try/catch, `extractModelCatalog` succeeds but `loadCatalogFromBundle` would throw: - -``` -cost Could not find cost data anchor -``` - -This matches the plan's expected RED failure mode. - -### GREEN - -After wrapping `buildCostMap(extractCostData(source))` in try/catch: - -``` -bun test tests/unit/catalog.test.ts - 10 pass - 0 fail -``` - -New test assertions pass: -- `sonnet` defined with `reasoningEfforts: ["low", "medium", "high"]` -- `sonnet.cost` equals `{ input: 0.5, output: 2 }` (default via empty cost map) - -Existing costful fixture still extracts numeric CLI costs (sonnet input 3, output 15). - -## Files Changed - -| File | Change | -| --- | --- | -| `src/catalog.ts` | `loadCatalogFromBundle`: optional cost map via try/catch | -| `tests/unit/catalog.test.ts` | New test for costless load | - -## Commit - -``` -fd990b7 fix: load command-code models when cost extraction fails -``` - -## Self-Review - -- **Correctness:** Production bundles have many models; catalog extraction succeeds, cost may throw — try/catch handles the real failure path (`command-code@1.38.0`). -- **Scope:** Did not change `extractCostData` or `plugin.ts`. No extra files added. -- **Regression:** Happy-path test unchanged and passing. -- **`loadCatalogFromLocalCommandCode`:** Already calls `loadCatalogFromBundle`; will stop returning `null` on cost-only failure (outer try/catch no longer needed for that case, but unchanged this task). - -## Concerns - -1. **Test fixture deviation:** The brief's exact 1-model source cannot pass `isModelCatalog` (requires ≥2 entries). The committed test adds a second model (`GPT_X`) with no cost block so RED fails on cost extraction as intended. Assertions still target Sonnet only. -2. **Plan RED expectation:** Plan Step 2 expected cost-anchor failure; verbatim 1-model fixture fails earlier at catalog extraction. Consider updating the plan/brief fixture or relaxing `isModelCatalog` in a follow-up if single-model test coverage is desired (CA-01). diff --git a/docs/2026-08-28-unblock-catalog/sdd/task-2-brief.md b/docs/2026-08-28-unblock-catalog/sdd/task-2-brief.md deleted file mode 100644 index 3158a11..0000000 --- a/docs/2026-08-28-unblock-catalog/sdd/task-2-brief.md +++ /dev/null @@ -1,10 +0,0 @@ -# Task 2 brief - -### Task 2: Quiet bundled startup - -Follow docs/2026-08-28-unblock-catalog/plan.md from heading ### Task 2 through the Task 2 commit step (stop before ### Task 3). Use the plan's exact test code, src/startup.ts, and plugin.ts config hook. - -CA-02 no console.log/warn by default. CA-03 bundled models.json. CA-04 empty models still registers npm/env. CA-05 last-good cache. Delete checkModelVersion. Do not call fetchModelsFromApi. Auth block unchanged. - -Work in-place on feature/2026-08-28-unblock-catalog. No worktrees. TDD then commit as specified. - diff --git a/docs/2026-08-28-unblock-catalog/sdd/task-2-report.md b/docs/2026-08-28-unblock-catalog/sdd/task-2-report.md deleted file mode 100644 index ad52995..0000000 --- a/docs/2026-08-28-unblock-catalog/sdd/task-2-report.md +++ /dev/null @@ -1,100 +0,0 @@ -# Task 2 Report: Quiet bundled startup - -## Status - -**Complete.** Review findings fixed: tests isolate state via `COMMANDCODE_PROVIDER_STATE_DIR`; CA-04 and CA-05 covered at plugin level. - -## Branch - -`feature/2026-08-28-unblock-catalog` (in-place, no worktrees) - -## Commits - -| Hash | Message | -|------|---------| -| `131ef76` | `fix: load bundled catalog without leaking plugin logs into OpenCode` | -| `fb0d878` | `fix: isolate plugin test state and add CA-04/CA-05 coverage` | - -## TDD workflow - -1. **Red** — Added `tests/unit/startup.test.ts` and the `config does not write to stdout or stderr by default` test in `tests/unit/plugin.test.ts`. Ran `bun test tests/unit/startup.test.ts tests/unit/plugin.test.ts`: startup module missing; plugin test failed on `console.log` and npm version-check `console.warn`. -2. **Green** — Created `src/startup.ts` with cache/summary helpers; rewrote `plugin.ts` config hook per plan. -3. **Verify** — `bun test tests/unit/plugin.test.ts tests/unit/startup.test.ts tests/unit/catalog.test.ts`: **28 pass, 0 fail**. - -## Files changed - -| File | Action | -|------|--------| -| `src/startup.ts` | Created — `pluginStateDir`, `readCatalogCache`, `writeCatalogCache`, `writeStartupSummary`, `StartupSummary` type; re-exports `ModelEntry` from `./catalog.js` | -| `plugin.ts` | Rewritten config hook; removed `checkModelVersion`, `fetchModelsFromApi`, `mergeModels`; renamed `loadModels` → `loadBundledModels`; added `readBundledVersion` | -| `tests/unit/startup.test.ts` | Created — cache round-trip and startup summary tests | -| `tests/unit/plugin.test.ts` | Added silent-startup contract test | - -## Spec coverage (Task 2) - -| Requirement | Implementation | -|-------------|----------------| -| CA-02: No console.log/warn by default | Config hook only logs when `debugStartupLogs === true` (via `console.warn` with JSON summary) | -| CA-03: Bundled models.json default | Loads bundled catalog unless `commandCodePackagePath` or `COMMANDCODE_PACKAGE_PATH` is set | -| CA-04: Empty models still registers npm/env | Always sets `cc.npm`, `cc.name`, `cc.env`; `cc.models = generateOpencodeModels([])` → `{}` on total failure | -| CA-05: Last-good cache | `readCatalogCache()` fallback when bundled read fails; `writeCatalogCache()` after successful non-empty load | -| Startup diagnostics file | `writeStartupSummary()` → `~/.local/state/opencode/commandcode-provider/startup.json` | -| No `fetchModelsFromApi` | Removed entirely from plugin startup path | -| Delete `checkModelVersion` | Removed | -| Auth unchanged | `auth` block left identical | - -## Config hook behavior (summary) - -``` -override set? → loadCatalogFromLocalCommandCode (opt-in-local) -else → loadBundledModels (bundled) - fail → readCatalogCache (cache, degraded) - fail → models=[], degraded -success + non-empty → writeCatalogCache -always → cc.models = generateOpencodeModels(models) -always → writeStartupSummary -debug only → console.warn summary JSON -``` - -## Test results - -``` -bun test tests/unit/plugin.test.ts tests/unit/startup.test.ts tests/unit/catalog.test.ts -28 pass, 0 fail, 66 expect() calls -``` - -Notable new assertions: - -- Plugin config produces zero stdout/stderr captures while still populating models from bundled `models.json`. -- Cache round-trips `ModelEntry[]` through temp dir. -- `startup.json` written with expected `catalogSource` and `modelCount`. - -## Concerns / follow-ups - -- **Task 3 scope**: `fetchModelsFromApi` / API context merge removed from startup; reintroduction would need a separate opt-in if desired. -- **Bundled catalog age**: `_version.txt` still reports `1.1.0`; Task 3 refresh will update bundled catalog and version. - -## Review fixes (post-131ef76) - -| Finding | Fix | -|---------|-----| -| Plugin tests wrote to `~/.local/state/...` | `pluginStateDir()` honors `COMMANDCODE_PROVIDER_STATE_DIR`; plugin tests set it to a temp dir | -| No plugin-level CA-04/CA-05 tests | Added CA-04 (bundled+cache miss → `{}` models, npm/env set) and CA-05 (unreadable bundled → cache fallback) | - -## Test results (review fix) - -``` -bun test tests/unit/plugin.test.ts tests/unit/startup.test.ts -21 pass, 0 fail, 44 expect() calls -``` - -New assertions: - -- `pluginStateDir()` returns env override when `COMMANDCODE_PROVIDER_STATE_DIR` is set. -- CA-04: config still sets npm/name/env; `cc.models` is `{}` when bundled and cache both miss. -- CA-05: unreadable bundled catalog loads models from last-good cache; `startup.json` reports `catalogSource: "cache"` and `degraded: true`. - -## Out of scope (not done) - -- Task 3: official-docs costs, `sync-models.ts` changes, catalog refresh -- GitHub Actions, npm rename, identity aliases diff --git a/docs/2026-08-28-unblock-catalog/sdd/task-3-brief.md b/docs/2026-08-28-unblock-catalog/sdd/task-3-brief.md deleted file mode 100644 index b4e18d5..0000000 --- a/docs/2026-08-28-unblock-catalog/sdd/task-3-brief.md +++ /dev/null @@ -1,12 +0,0 @@ -# Task 3 brief - -### Task 3: Official-docs costs and refresh bundled catalog - -Follow docs/2026-08-28-unblock-catalog/plan.md heading ### Task 3 through its commit step. - -Create src/costs-docs.ts, tests/unit/costs-docs.test.ts, tests/fixtures/command-code/models-page.md. Wire scripts/sync-models.ts. Run bun run sync --remote to refresh models.json and _version.txt from latest command-code tarball. CLI costs win; docs fill gaps; fallback last. Exact id then exact name. Do not fail sync if docs parse empty. - -CA-06: _version.txt is not 1.1.0 after sync. CA-07: docs costs applied when CLI costs missing. - -Work in-place on feature/2026-08-28-unblock-catalog. No worktrees. TDD then commit as specified. - diff --git a/docs/2026-08-28-unblock-catalog/sdd/task-3-report.md b/docs/2026-08-28-unblock-catalog/sdd/task-3-report.md deleted file mode 100644 index 4fafb9e..0000000 --- a/docs/2026-08-28-unblock-catalog/sdd/task-3-report.md +++ /dev/null @@ -1,97 +0,0 @@ -# Task 3 Report: Official-docs costs and refresh bundled catalog - -## Status - -**DONE_WITH_CONCERNS** - -## Branch - -`feature/2026-08-28-unblock-catalog` (in-place, no worktrees) - -## Summary - -Added official-docs cost fill (`src/costs-docs.ts`) and wired `bun run sync --remote` to refresh the committed catalog from the latest `command-code` npm tarball. CLI cost ids win when extract succeeds; docs fill remaining rows by exact id then exact name; `buildModelEntry` fallback is last. Empty docs parse does not fail sync. - -Live `/models` is an HTML table with extra columns (`Intelligence`, `Tok/s`, `Caps`), not the markdown fixture shape. Parser uses header names (not guessed indexes) and HTML ``/`` after a real snippet fixture. - -## TDD Evidence - -### RED - -1. `tests/unit/costs-docs.test.ts` + `tests/fixtures/command-code/models-page.md` before `src/costs-docs.ts`: - -``` -error: Cannot find module '../../src/costs-docs.ts' - 0 pass, 1 fail -``` - -2. After markdown parser green, live HTML fixture test (header names, extra columns): - -``` -expect(sonnet).toEqual({...}) -+ undefined -(fail) parseModelsTable > parses live HTML using header names not column indexes -``` - -### GREEN - -``` -bun test tests/unit/costs-docs.test.ts - 4 pass, 0 fail, 17 expect() calls -``` - -### Refresh - -``` -bun run sync --remote - Latest version: 1.38.1 - Found 65 models - Applied official-docs costs to 60 models (0 kept CLI costs) -``` - -`_version.txt` is `1.38.1` (not `1.1.0`). CA-06 met. - -## Files Changed - -| File | Change | -| --- | --- | -| `src/costs-docs.ts` | Created — `parseMoneyCell`, `parseModelsTable` (pipe + HTML, header-named columns), `applyDocCosts`, `fetchOfficialModelsMarkdown` | -| `src/catalog.ts` | `LocalCatalogResult.bundleSource` for CLI cost-id tracking | -| `scripts/sync-models.ts` | After catalog load: `cliCostIds` + official-docs fill; console.log allowed | -| `tests/unit/costs-docs.test.ts` | Fixture + live HTML tests | -| `tests/fixtures/command-code/models-page.md` | Plan markdown fixture | -| `tests/fixtures/command-code/models-page.live.md` | Real `/models` table snippet (header + representative rows) | -| `models.json` | Regenerated, 65 models from command-code@1.38.1 | -| `_version.txt` | `1.38.1` | - -## Commit - -``` -c26d6fc feat: fill catalog costs from official docs and refresh bundled models -``` - -## Spec coverage (this task) - -| Requirement | Result | -|---|---| -| CA-06: `_version.txt` is not 1.1.0 after sync | `1.38.1` | -| CA-07: docs costs applied when CLI costs missing | 60/65 filled; 0 CLI ids (extract failed on 1.38.1 bundle) | -| Exact id then exact name | `applyDocCosts`: `byId` then `byName`, both case-insensitive | -| Do not fail sync if docs parse empty | `fetchOfficialModelsMarkdown` returns `""`; sync still writes | -| Plugin startup stays quiet | `plugin.ts` unchanged | - -## Test results - -``` -bun test tests/unit/costs-docs.test.ts tests/unit/catalog.test.ts tests/unit/plugin.test.ts tests/unit/startup.test.ts - all pass (plugin `withVariants` still holds; 31 models have reasoningEfforts) -``` - -Full `bun test tests/unit/`: **91 pass, 3 fail**. The 3 failures are pre-existing and unrelated to this task: `index.test.ts` / `auth.test.ts` still see a key from `~/.commandcode/auth.json` after deleting `COMMANDCODE_API_KEY`. - -## Concerns - -1. **CLI cost extract still fails on command-code@1.38.1** (`cliIds.size === 0`). Docs filled 60 models; 5 remain on `{ input: 0.5, output: 2 }` (name/id mismatch vs docs, e.g. Ling/MiniMax/Hy3 Free variants). When CLI extract starts working, those ids will win over docs. -2. **Docs id slugs vs catalog ids**: href `/models/gemini-3-7-flash` vs catalog `google/gemini-3.7-flash`. Name match saved billed Gemini 3.7 Flash (`$0.75/$3.75`). No fuzzy id mapping (spec: exact id then exact name). -3. **Live HTML shape**: extra columns; dual prices as `$1.50 $0.75`; visible name from `.truncate` span; id from `/models/{slug}`. Parser follows `models-page.live.md`, not guessed indexes. -4. **Full unit suite**: 3 auth/index failures when a local Command Code auth file exists; not introduced here. diff --git a/docs/2026-08-28-unblock-catalog/spec.md b/docs/2026-08-28-unblock-catalog/spec.md index f46fea0..de9d54d 100644 --- a/docs/2026-08-28-unblock-catalog/spec.md +++ b/docs/2026-08-28-unblock-catalog/spec.md @@ -1,5 +1,6 @@ # Spec: Unblock Command Code catalog in OpenCode +Status: shipped (0.5.0, 2026-08-28) **Branch:** `feature/2026-08-28-unblock-catalog` Parent specs: @@ -22,7 +23,7 @@ OpenCode cannot use the Command Code plugin reliably on current `command-code` r ## Non-goals - GitHub Actions catalog-sync workflow and `catalog-break` issues -- npm rename/publish (`@brainervirus/commandcode-go-opencode-provider`) +- npm rename/publish (`@brainervirus/opencode-commandcode`) - Stable identity keys, alias migration, `(new)` badges - Third-party cost API (`COST_ENRICHMENT_API_URL`) - Reasoning trace stream adapter @@ -52,7 +53,9 @@ flowchart TD models2 --> cliCosts{CLI costs?} cliCosts -->|ok| keep[Keep CLI costs] cliCosts -->|fail| docs[Official /models then pricing-limits] - docs --> fallback[Hardcoded fallback for unmatched] + docs --> free[Free SKUs $0] + free --> modelsDev[models.dev reference costs] + modelsDev --> fallback[Unmatched placeholder] keep --> write[Write models.json + _version.txt] fallback --> write ``` @@ -77,7 +80,7 @@ OpenCode loading (plugin must not add lines): | opt-in local | scrape local CLI only if `commandCodePackagePath` or `COMMANDCODE_PACKAGE_PATH` is set | | billed rate | last `$` amount in a docs cell (deal-adjusted) | -Runtime never fetches prices. `bun run sync` fills costs: CLI → official docs (`https://commandcode.ai/models`, then pricing-limits) → hardcoded fallback. Exact id then exact display name. +Runtime never fetches prices. `bun run sync` fills costs: CLI → official docs (`https://commandcode.ai/models`, then pricing-limits) → free SKUs (`$0`) → models.dev reference → unmatched placeholder. Exact id then exact display name. ## Acceptance criteria @@ -93,11 +96,16 @@ Runtime never fetches prices. `bun run sync` fills costs: CLI → official docs - D-01: Default runtime source is bundled JSON, not local CLI scrape. - D-02: Diagnostics go to `~/.local/state/opencode/commandcode-provider/startup.json`. -- D-03: CI/npm publish and identity aliases wait for later plans. +- D-03: CI/npm publish shipped later; identity aliases never shipped (see Follow-ups). -## Future work +## Follow-ups -- CI catalog-sync + `catalog-break` issues -- npm publish as `@brainervirus/commandcode-go-opencode-provider` -- Stable model identity / favorites aliases -- Optional `COST_ENRICHMENT_API_URL` +Shipped after this spec: + +- CI catalog-sync + `catalog-break` issues — see [ci-catalog-automation](../specs/2026-08-28-ci-catalog-automation.md). +- npm publish as `@brainervirus/commandcode-go-opencode-provider` (0.5.0), renamed to `@brainervirus/opencode-commandcode` (0.6.0). + +Not shipped: + +- Stable model identity aliases / favorites migration. +- Optional `COST_ENRICHMENT_API_URL`. diff --git a/docs/specs/2026-08-19-stable-model-identity.md b/docs/specs/2026-08-19-stable-model-identity.md index c692a75..d93e55f 100644 --- a/docs/specs/2026-08-19-stable-model-identity.md +++ b/docs/specs/2026-08-19-stable-model-identity.md @@ -1,428 +1,94 @@ -# Command Code OpenCode Provider — Stable Identity and Runtime Resilience +# Command Code OpenCode Provider — Runtime Identity and Resilience -Status: draft (updated 2026-08-28) +Status: shipped (updated 2026-09-26) ## Goal -Keep Command Code working reliably in OpenCode while preserving user model preferences (favorites, recents, variants) across plugin startups. +Keep Command Code working reliably in OpenCode across Command Code release drift: no local `command-code` install required, no plugin hard-failure when costs are missing, and no startup output leaking into the OpenCode UI. -This spec covers: -- Stable model identity for persistence -- Safe handling of renamed/removed models -- Multiple active model flavors shown as separate entries -- Optional `(new)` UI labeling -- **Runtime resilience against Command Code release drift** -- **Non-blocking startup (no leaked loading output into OpenCode UI)** -- **Reasoning metadata and reasoning trace compatibility** +This spec describes the runtime behavior that ships. An earlier draft additionally proposed canonical ids, an alias table, `model.json` state migration, `(new)` badges, and reasoning-trace modes; those were never implemented (see Non-goals) and are intentionally absent. -## Current Breakage (verified 2026-08-28) +## Catalog source priority -Observed on local machine with: -- OpenCode 1.18.x -- `command-code@1.37.0` (global) / `@1.38.0` (latest npm) -- Hybrid config: FanFan plugin for metadata + `@ai-sdk/openai-compatible` for inference +`loadCatalogEntries()` in `plugin.ts` resolves the catalog in this order: -### Root cause 1: catalog extraction hard-fails on newer Command Code +1. **Opt-in local override** — only when `COMMANDCODE_PACKAGE_PATH` or `commandCodePackagePath` is set. A failed override is recorded and ignored. +2. **Bundled `models.json`** from the installed package (npm or `file://` checkout). Default runtime source. +3. **Last-good cache** — `catalog-cache.json` in the plugin state dir (`~/.local/state/opencode/commandcode-provider`, override `COMMANDCODE_PROVIDER_STATE_DIR`). Used only when bundled `models.json` is unreadable, invalid JSON, or not an array. -`loadCatalogFromLocalCommandCode()` currently returns `null` when **cost extraction** fails, even if model catalog extraction succeeds. +Every load with models rewrites the cache; every load writes `startup.json`. Write failures never fail startup. -Verified on `command-code@1.38.0`: -- model catalog extraction: **success** (~65 models) -- cost extraction: **fail** (`Could not evaluate cost data map`) -- net result: plugin treats catalog as unavailable +## Validation boundaries -Impact: -- plugin falls back to bundled `models.json` (stale, `_version.txt` = `1.1.0`) -- reasoning efforts/variants missing or wrong -- favorites appear broken when model IDs no longer match current catalog -- connect may appear broken because provider metadata is incomplete/outdated +- `src/schemas.ts` (zod) is the source of truth for `ModelEntry` and `CatalogManifest`. `src/catalog.ts` and `src/manifest.ts` derive their types from it. +- Bundled `models.json` is parsed entry by entry; invalid entries are dropped and counted (first three reasons kept). Dropping any entry marks the load `degraded`. +- Bundled `manifest.json` is validated; a `degraded`/`broken` status marks the load `degraded` with the matching reason. +- Provider availability payloads (`AvailabilityPayloadSchema`) are validated in CI sync (`parseAvailabilityIds`), never at runtime. +- The plugin config file is parsed leniently (`PluginFileConfigSchema`): unknown keys are stripped and missing fields fall back to defaults. -### Root cause 2: startup output leaks into OpenCode loading UI - -Plugin currently uses `console.log` / `console.warn` during `config` hook: -- `[commandcode] Loaded N models from ...` -- version update warnings - -OpenCode startup surfaces plugin stdout/stderr in loading UI. This is useful for debugging but unacceptable as default UX. - -### Root cause 3: reasoning traces vs reasoning metadata are conflated - -Hybrid transport (`@ai-sdk/openai-compatible` → `/provider/v1/chat/completions`) and native transport (`/alpha/generate` with custom stream parser) behave differently. - -Two separate features must be specified: -1. **Reasoning metadata** (model capabilities + effort variants for `/models`, `Ctrl+T`) -2. **Reasoning traces** (streamed thinking content shown in session UI) - -Using openai-compatible transport can preserve effort selection while **not** guaranteeing reasoning trace rendering unless stream parts are mapped correctly. - -## Problem (persistence) - -OpenCode persists model state in `~/.local/state/opencode/model.json` using exact tuples: -- `favorite[]`: `{ providerID, modelID }` -- `recent[]`: `{ providerID, modelID }` -- `variant{}` keyed by `providerID/modelID` - -If model IDs drift between startups, persisted entries no longer match `provider.models[modelID]`. - -## Non-Goals - -- Do not change OpenCode core behavior. -- Do not use fuzzy name matching to migrate IDs. -- Do not auto-delete user favorites when a model disappears. -- Do not use undocumented `/alpha/generate` as default transport (GOAT must stay on documented Provider API). - -## Design Overview - -Introduce a deterministic catalog reconciliation pipeline: - -1. Load bundled `models.json` (CI-synced; primary runtime source). No local `command-code` install required. -2. Optionally enrich with Provider API `/v1/models` (availability/context). -3. Resolve costs with the CI waterfall (CLI → official docs → optional trusted API → hardcoded last). **Never fail catalog load because costs are missing.** Runtime uses whatever costs were bundled; it does not fetch prices at OpenCode startup. -4. Optional maintainer override: extract from a local `command-code` package only when `commandCodePackagePath` or `COMMANDCODE_PACKAGE_PATH` is set. -5. Apply explicit alias mappings for known renames. -6. Preserve distinct active flavors as separate models. -7. Migrate local model state with safe, idempotent rules. -8. Run startup work asynchronously/non-blocking; log to plugin diagnostics channel, not stdout by default. - -ponytail: cost parsing is optional enrichment; model catalog is the hard requirement. Local CLI scrape is opt-in, not the default path. - -## Command Code Compatibility Policy - -### Supported extraction contract - -The plugin must treat these as independent subsystems: -- `modelCatalog`: required -- `costCatalog`: optional -- `providerApiModels`: optional - -Failure matrix: +## Failure matrix | Subsystem failure | Required behavior | |---|---| -| bundled `models.json` missing/corrupt | last-good cache if present; else empty models map; auth/connect still works | -| costCatalog missing after full waterfall | continue with hardcoded fallback for those models; `degraded=true` | -| provider API fails | continue with bundled catalog only | -| opt-in local extract fails | ignore override; use bundled catalog | -| all sources fail | provider still registers; auth/connect works; models list may be empty | - -### Version tracking - -Track and log: -- bundled `commandCodeVersion` from `manifest.json` (not a local CLI version) -- catalog model count -- reasoning-capable model count -- source used (`bundled`, `cache`, `opt-in-local`, `mixed`) - -CI automation (see [2026-08-28-ci-catalog-automation.md](./2026-08-28-ci-catalog-automation.md)) watches `command-code@latest` and syncs `models.json`. When `NPM_TOKEN` is set it also publishes plugin patches. Users do **not** need local `command-code` or `bun run sync`. - -### Pinning guidance for users - -Document recommended install: -- after first npm publish: `"plugin": ["@brainervirus/commandcode-go-opencode-provider@latest"]` (or pin) -- until then: `file://` checkout (no auto-update; `git pull` after CI/catalog commits) -- no local `command-code` required for catalog updates -- if startup degraded, update the plugin package (or pull this repo); do not install the CLI as a fix -- local `command-code` scrape remains opt-in via `commandCodePackagePath` - -## Identity Rules - -### 1) Canonical ID - -Each model entry has: -- `canonicalId`: stable persistence identity (string) -- `providerModelId`: current API request ID (string) - -In most cases these are equal. - -Canonical ID authority order (deterministic): -1. Explicit alias resolution target (if legacy ID mapped) -2. Bundled catalog ID (`models.json`) -3. Opt-in local `command-code` extracted ID (only when override is enabled) -4. Provider API ID (`/provider/v1/models`) - -Conflict rule: -- If bundled catalog and API disagree on ID shape in one startup, prefer bundled ID and record API ID as `providerModelId`. -- Never rewrite `canonicalId` based only on display name changes. - -### 2) Stable Key in OpenCode Model Map - -The key used in `provider.commandcode.models` must be stable and derived from `canonicalId`. - -Rule: -- `modelKey = canonicalId.toLowerCase()` -- keep `/` segments (do not shorten to suffix-only keys) - -Example: -- `deepseek/deepseek-v4-flash` stays `deepseek/deepseek-v4-flash` - -### 3) Flavors Stay Separate - -If two models have different `canonicalId`s and both are callable, both remain visible as standalone entries, even if display names are similar. - -Display disambiguation rule for active same-name models: -- append stable suffix (display-only): - - preferred: `()` - - fallback: `()` - -## Rename and Alias Policy - -### Alias Table - -```ts -type AliasMap = Record -// legacyId -> canonicalId -``` - -Constraints: -- exact-string matches only -- provider-aware where needed -- no regex/fuzzy behavior -- versioned and reviewed in-repo - -Lifecycle policy: -- aliases append-only by default -- removal only after one minor release cycle with zero unresolved migration hits -- each alias entry documents source/reason/first plugin version - -### Alias Application - -When a legacy ID exists in user state: -- if exactly one canonical target is mapped and exists, migrate it -- if target does not exist, keep original entry and mark unresolved in logs -- if ambiguous, skip migration and log warning - -## Removed Model Policy - -If a favorited model no longer exists in current catalog: -- keep it in state (do not delete automatically) -- surface as unresolved in startup summary -- unresolved entries must never block plugin startup - -## New Model Labeling - -Support metadata tags without changing identity: -- `isNew: boolean` -- `firstSeenAt` timestamp in plugin-managed metadata file - -Behavior: -- `isNew=true` for models first seen within configurable window (default 14 days) -- append ` (new)` to display name only - -Metadata reset behavior: -- if metadata file missing/corrupt, rebuild from current catalog -- mark only unknown entries as new from rebuild time -- do not mark all models as new when backup exists - -## Startup Lifecycle (must not leak into OpenCode loading UI) - -### Requirements - -1. `config` hook must return quickly; heavy work should be deferrable/non-blocking where OpenCode allows. -2. Default logging target is plugin diagnostics (structured), **not stdout/stderr**. -3. `console.log`/`console.warn` in startup path are forbidden unless `debugStartupLogs=true`. -4. Version check (`npm latest`) must never block model registration. -5. Provider auth/connect registration must succeed even when catalog is degraded. - -### Startup phases - -1. **Register auth/connect immediately** (always). -2. **Load catalog** (best effort, bounded timeout). -3. **Apply alias migration** to `model.json` (optional, safe). -4. **Publish startup summary** to diagnostics channel. - -### Degraded mode UX - -When degraded: -- show one concise warning in diagnostics (not repeated per startup line) -- include actionable hint: update the plugin npm package (or `git pull` if using `file://`); do not tell users to install `command-code` locally -- keep previously working favorites if IDs still resolvable - -## Reasoning Support - -### A) Reasoning metadata (required) - -For each model with `reasoningEfforts` in Command Code catalog: -- set `reasoning: true` -- generate OpenCode variants: - - `variants[effort] = { reasoningEffort: effort }` - -If catalog extraction fails and fallback catalog lacks efforts, mark model as reasoning-capable only when explicitly known; do not invent effort levels. - -### B) Reasoning traces (stream content) - -Define transport capability explicitly: - -| Transport | Endpoint | Effort param | Reasoning trace rendering | -|---|---|---|---| -| Hybrid (`@ai-sdk/openai-compatible`) | `/provider/v1/chat/completions` | via `reasoning_effort` | depends on OpenCode + SDK stream mapping | -| Native provider adapter | `/alpha/generate` | custom request builder | full reasoning-delta mapping in plugin | - -Policy: -- GOAT production default remains hybrid/documented Provider API. -- Reasoning trace gaps in hybrid mode are treated as **compatibility limitations**, not silent success. -- Plugin must expose capability flag per transport in startup summary: - - `reasoningVariants: true/false` - - `reasoningTraceStreaming: supported|limited|unsupported` - -Acceptance for hybrid: -- effort cycling works for models with declared efforts -- if trace parts are absent, UI may show no thinking block (expected limitation unless stream adapter added) - -Future optional enhancement (out of initial scope): -- add Provider-API-compatible stream adapter that maps reasoning chunks without switching to `/alpha/generate`. - -## State Migration - -### Files - -OpenCode state file: -- `~/.local/state/opencode/model.json` - -Backup file: -- `~/.local/state/opencode/model.json.bak` - -Path fallback: -- resolve via OpenCode runtime state directory when available -- fallback `~/.local/state/opencode/model.json` -- if unreadable/unwritable: skip migration, continue startup - -### Migration scope - -- `favorite[]` -- `recent[]` -- `variant{}` keys - -### Migration safety rules - -1. backup once before first write -2. rewrite only deterministic alias mappings -3. never drop unknown entries -4. atomic write -5. idempotent -6. migration errors never fail plugin startup - -## Logging and Observability - -Startup summary fields: -- `catalogSource` (`bundled|cache|opt-in-local|mixed`) -- `commandCodeVersion` -- `modelCount` -- `reasoningModelCount` -- `migratedCount` -- `unresolvedCount` -- `ambiguousCount` -- `newModelCount` -- `degraded` (boolean) -- `degradedReason` (string|null) -- `reasoningVariantsEnabled` -- `reasoningTraceMode` (`supported|limited|unsupported`) - -Warn-level diagnostics for: -- ambiguous alias target -- alias target missing -- state file I/O skipped -- modelCatalog extraction failure -- costCatalog extraction failure (non-fatal) - -## Config Flags - -`~/.config/opencode/commandcode-go-opencode-provider.json`: - -```json -{ - "enableStateMigration": true, - "enableNewBadge": true, - "newBadgeDays": 14, - "migrationReadOnlyOnError": true, - "debugStartupLogs": false, - "startupCatalogTimeoutMs": 5000, - "commandCodePackagePath": null -} -``` - -Defaults: -- migration: `true` -- new badge: `true` -- new badge window: `14` -- read-only on migration error: `true` -- debug startup logs: `false` -- startup catalog timeout: `5000` -- commandCodePackagePath: unset (bundled catalog; local CLI scrape is opt-in) - -Costless catalog is always on at **runtime** (do not block OpenCode on prices). CI still tries to fill costs via the waterfall in [2026-08-28-ci-catalog-automation.md](./2026-08-28-ci-catalog-automation.md) before shipping the bundle. - -## Related Specs - -- [2026-08-28-ci-catalog-automation.md](./2026-08-28-ci-catalog-automation.md) — CI sync, npm auto-release, catalog-break issues - -## Implementation Plan - -Locked order (this spec + CI spec). Do not ship identity key changes before a current catalog is bundled. - -### Phase 1 — unblock startup and catalog (priority) +| bundled `models.json` missing/corrupt | last-good cache if present; else empty models map | +| bundled manifest `degraded`/`broken` | continue; mark the load degraded with reason | +| cost data missing after the CI waterfall | continue; unmatched costs keep the placeholder and the manifest is `degraded` | +| provider availability call fails in sync | no artifact writes and a `catalog-break` issue; the previous catalog stays | +| opt-in local extract fails | ignore override; use bundled | +| auth/connect | registers on V1 regardless of catalog state | -1. Split catalog loader: model catalog required, cost optional (always). -2. Refresh committed `models.json` from latest `command-code` tarball plus official-docs cost fill (one-shot sync so bundled catalog is not `_version.txt` 1.1.0). -3. Add last-good catalog cache file in plugin state dir. -4. Remove stdout startup logs; route to diagnostics. -5. Ensure auth/connect registers before catalog finishes. -6. Add startup summary + degraded mode. -7. Default runtime source = bundled catalog; local CLI scrape only when override is set. +Degraded reporting is internal: `startup.json` carries `degraded` and `degradedReason`, and V1/V2 registration is unchanged. There is no separate degraded UI. -CI automation after Phase 1: [2026-08-28-ci-catalog-automation.md](./2026-08-28-ci-catalog-automation.md) Phases B–D. +## Stable model identity -### Phase 2 — identity persistence +- Catalog entry `id` is the Command Code wire id (for example `deepseek/deepseek-v4.1-flash`) and is what reaches the API. Bare short names 400 as `unsupported_model`. +- The OpenCode map key (and V2 model `id`) is `toConfigKey(entry.id)` in `src/catalog.ts`: the last `/` segment, lowercased (`deepseek-v4.1-flash`). +- V1: `provider.commandcode.models[key] = { id: entry.id, ... }`. V2: `{ id: toConfigKey, modelID: entry.id }`. +- No alias table and no state migration: OpenCode owns favorites/recents/variants, and removing a model never edits that state. Persisted selections survive as long as the map key is unchanged. +- Same-name flavors stay separate entries; the catalog id disambiguates them. -1. Add `canonicalId` + stable key generation (`canonicalId.toLowerCase()`, keep `/`). -2. Ship alias map **including current `toConfigKey()` suffix-only keys → slash-qualified keys** so existing favorites survive the key-format change. -3. Add state migrator, flavor disambiguation, and `(new)` badge logic. +## Reasoning metadata -### Phase 3 — reasoning trace clarity +- When the bundled entry declares `reasoningEfforts`, V1 emits `variants[effort] = { reasoningEffort: effort }` and V2 emits `variants: [{ id, settings: { reasoningEffort } }]`. `generateOpencodeModels()` and `toV2Model()` must stay in parity. +- Reasoning trace rendering is transport/OpenCode-dependent; there is no stream adapter and no `reasoningTraceMode` reporting. -1. Document and test hybrid reasoning variant forwarding. -2. Add explicit `reasoningTraceMode` reporting. -3. Evaluate optional Provider-API stream adapter (only if needed). +## Startup behavior -## Test Plan +- Default is quiet: no `console.log`/`console.warn` in the plugin load path. +- `~/.local/state/opencode/commandcode-provider/startup.json` records `catalogSource` (`bundled`/`cache`/`opt-in-local`), `commandCodeVersion`, `modelCount`, `reasoningModelCount`, `degraded`, and `degradedReason`. +- `debugStartupLogs: true` mirrors the summary to stderr once. +- V1 `server()` registers provider defaults and the API-key auth method; V2 `setup()` adds/updates the provider inventory and models through transforms. Auth stays V1-only. -### Unit +## Config -- model catalog succeeds when cost extraction fails (1.38 fixture) -- degraded mode falls back to cache, then bundled models -- no stdout logs unless `debugStartupLogs=true` -- stable key generation preserves slash-qualified IDs -- alias migration idempotency -- same-name flavors remain separate with deterministic display suffix +`~/.config/opencode/opencode-commandcode.json` (legacy name `commandcode-go-opencode-provider.json` still loads): -### Integration +| Key | Default | Effect | +|---|---|---| +| `commandCodePackagePath` | `""` | opt-in local CLI override | +| `debugStartupLogs` | `false` | mirror the startup summary to stderr | +| `disableModelSync` | `false` | accepted for forward compatibility; no effect | -- OpenCode startup **without** a local `command-code` install registers provider and connect flow from bundled catalog -- `/models` includes current models with reasoning variants when the bundle has `reasoningEfforts` -- favorites persist across restart when alias mapping exists (including suffix-only → slash-qualified) -- hybrid request includes `reasoning_effort` for selected variant -- startup UI does not show plugin loading spam +## Non-goals (not shipped) -## Risks and Mitigations +- `canonicalId` / `providerModelId` alias table and `~/.local/state/opencode/model.json` migration; current map keys are deliberately suffix-only (`toConfigKey`). +- `(new)` badges, `firstSeenAt` metadata, `newBadgeDays`. +- Runtime Provider API merge or runtime price fetch. +- Reasoning trace stream adapter / `reasoningTraceMode`. +- `COST_ENRICHMENT_API_URL` third-party cost API. -- Command Code bundle format drift - Mitigation: CI against latest tarball + costless catalog path. +If favorites migration is ever needed, reopen it as a new spec against the current `toConfigKey` keys. -- Incorrect alias migration - Mitigation: exact-only aliases + unresolved fallback + `.bak`. +## Test coverage -- Reasoning trace expectation mismatch in hybrid mode - Mitigation: explicit capability reporting; docs state limitation clearly. +Unit tests exercise the shipped contract (`tests/unit/plugin.test.ts`, `startup.test.ts`, `schemas.test.ts`, `catalog.test.ts`, `v2models.test.ts`, `auth.test.ts`): -- Stale bundled fallback (`models.json`) - Mitigation: CI npm patch from latest tarball; last-good cache only if the installed bundle is corrupt. `file://` installs do not auto-update — git pull or switch to npm. +- bundled load, cache fallback, and dropped-entry degraded reasons +- V1 map key vs wire id; V2 `id` vs `modelID` +- reasoning variant parity between V1 and V2 +- startup summary written; no stdout by default -## Acceptance Criteria +## Related specs -- Plugin startup never hard-fails due to cost extraction regression in new Command Code releases. -- OpenCode loading UI is not polluted by plugin stdout logs by default. -- Connect/auth works even in degraded catalog mode. -- Favorites/recents/variants survive restarts for mapped IDs. -- Multiple active flavors remain standalone and selectable. -- Reasoning effort variants appear when the bundled catalog provides `reasoningEfforts`. -- Spec clearly distinguishes reasoning variants vs reasoning trace streaming behavior. -- Existing favorites using suffix-only keys migrate via explicit aliases when slash-qualified keys ship. +- [CI catalog automation](./2026-08-28-ci-catalog-automation.md) +- [Catalog freshness](./2026-09-20-catalog-freshness.md) diff --git a/docs/specs/2026-08-28-ci-catalog-automation.md b/docs/specs/2026-08-28-ci-catalog-automation.md index b935caa..a9638e1 100644 --- a/docs/specs/2026-08-28-ci-catalog-automation.md +++ b/docs/specs/2026-08-28-ci-catalog-automation.md @@ -1,6 +1,6 @@ # CI Catalog Automation — Zero Local Command Code -Status: draft (2026-08-28) +Status: shipped (updated 2026-09-26) ## Goal @@ -24,13 +24,14 @@ command-code npm publish │ ├─ download tarball ├─ extract model catalog (required) - ├─ extract costs (CLI, then official docs, then optional API) - ├─ write models.json + manifest.json + ├─ filter to callable provider-API ids (below floor = broken) + ├─ fill costs (CLI → official docs → free SKUs → models.dev → placeholder) + ├─ write models.json + _version.txt + manifest.json │ - ├─ SUCCESS ──► bump patch + commit ──► npm publish if NPM_TOKEN set ──► GitHub Release + ├─ SUCCESS ──► open fix(catalog) PR ──► auto-merge after checks ──► semantic-release npm publish + GitHub Release │ └─ MODEL FAIL ──► open/update issue (catalog-break) ──► fail workflow - COST FAIL only ──► degraded manifest ──► still release (patch) + COST GAPS only ──► degraded manifest ──► still release (patch) ``` ## Repo Artifacts @@ -69,7 +70,7 @@ Human- and CI-readable metadata about the bundled catalog. Shipped with the plug } ``` -Write order: bump `package.json` version first, then write `manifest.json` with that `pluginVersion`, then one commit. Do not store `gitCommit` in the file (unknown until after commit; put SHA on the GitHub Release instead). +Write order: `scripts/sync-models.ts` writes `models.json`, `_version.txt`, then `manifest.json` with the current `package.json` version. Semantic-release rewrites `manifest.pluginVersion` from `nextRelease.version` in its prepare phase before packing. Do not store `gitCommit` in the file (unknown until after commit; put SHA on the GitHub Release instead). `status` rules: - `healthy`: model catalog ok **and** every model has a sourced price (`cli`, `officialDocs`, `thirdParty` / models.dev, or `free`). `unmatched` = 0 @@ -121,31 +122,26 @@ Out of scope: `repository_dispatch` watchers. Steps: 1. Read npm `command-code@latest` version. -2. **Idempotency (do not skip publish retry):** - - If `commandCodeVersion` unchanged and `force` is false: skip extraction. - - Still run **publish retry** if the current `package.json` version is not on npm (previous job committed but publish failed). - - If version unchanged, npm already has that plugin version, and `force` is false: exit. +2. **Idempotency:** if `commandCodeVersion` is unchanged and `force` is false, skip extraction. Unpublished-plugin-version retries are owned by the release job (`release.yml`), not catalog-sync. 3. Download tarball (reuse logic from `scripts/sync-models.ts --remote`). -4. Extract models (required) then run the **cost waterfall**: - - model catalog fail → status `broken`, stop (no commit, no publish). +4. Extract models (required), filter to the provider-API callable ids, then run the **cost waterfall**: + - model catalog fail, availability request fail, or filtered count below floor → status `broken`, no artifact writes, no PR. - CLI costs fail or partial → continue; fill gaps from official docs, then free SKUs ($0), then models.dev, then unmatched placeholder. - Record `costSources` on the manifest. `degraded` only if any model is still unmatched. 5. Sanity floor: `modelCount >= max(20, floor(lastSuccessfulModelCount * 0.5))`. `lastSuccessfulModelCount` is `modelCount` from the last committed manifest with `status` `healthy` or `degraded`. Fail as `broken` if below. If no prior manifest, use `20`. -6. Write `models.json`, `_version.txt`, bump patch in `package.json`, write `manifest.json`. -7. Run unit tests (including 1.38 costless fixture). +6. Write `models.json`, `_version.txt`, `manifest.json` (semantic-release owns `package.json` versions). +7. CI on the opened PR runs unit tests (including the 1.38 costless fixture). 8. If status is `broken`: call issue opener, fail job. -9. If models changed or command-code version changed: - - auto-commit to `main` (catalog + manifest + version only) - - npm publish if `NPM_TOKEN` is set; if missing, skip publish and record that in the workflow summary (Phase D not wired) - - GitHub Release after successful publish only +9. If generated catalog files changed: commit to `chore/catalog-sync` as `fix(catalog): sync command-code@X`, open/update the PR to `main`, and queue `gh pr merge --auto --squash --delete-branch`. Commit strategy (locked): -- **Auto-commit to `main`** for catalog-only generated files (`models.json`, `manifest.json`, `_version.txt`, `package.json` version). +- **Bot PR, not direct pushes:** catalog-only generated files (`models.json`, `manifest.json`, `_version.txt`) go to `chore/catalog-sync` with a `fix(catalog)` commit subject, and auto-merge after the required checks. - **Human PR required** for any change to `src/`, tests, or workflow files. The catalog workflow must never commit extraction-code changes. +- Merge to `main` triggers `release.yml` semantic-release (npm publish + tag + GitHub Release). -ponytail: auto-commit is for deterministic generated data only. +ponytail: auto-merge is for deterministic generated data only. -Workflow permissions: `contents: write`, `issues: write`. If branch protection blocks GITHUB_TOKEN pushes, use a fine-grained PAT stored as `CATALOG_PUSH_TOKEN` — do not fall back to a second “open a PR instead” path in the same workflow. +Workflow permissions: `contents: write`, `issues: write`, `pull-requests: write`. PRs are opened with `RELEASE_SYNC_TOKEN` (PAT) so GitHub starts CI on the branch; `GITHUB_TOKEN`-opened PRs do not trigger workflows. `CATALOG_PUSH_TOKEN` remains a fallback secret name. ### 2. `catalog-break-issue.yml` (called from sync on failure) @@ -180,14 +176,12 @@ When a subsequent sync succeeds after manual fix: Published npm name (locked, same account as workit: `brainervirus`): ```json -"plugin": ["@brainervirus/commandcode-go-opencode-provider@latest"] +"plugin": ["@brainervirus/opencode-commandcode@latest"] ``` -`package.json` `name` becomes `@brainervirus/commandcode-go-opencode-provider` with `publishConfig.access: "public"`. This stays its own repo; it is not folded into `workflow-toolkit`. +`package.json` `name` is `@brainervirus/opencode-commandcode` with `publishConfig.access: "public"` (published as `@brainervirus/commandcode-go-opencode-provider` before the 0.6.0 rename). This stays its own repo; it is not folded into `workflow-toolkit`. -Until Phase D (`NPM_TOKEN` GitHub secret — local `npm whoami` is not enough for Actions): -- maintainer install stays `file://.../plugin.ts` -- `file://` is **not** auto-updated by CI; catalog changes require `git pull` of this repo +`file://` installs are **not** auto-updated by CI; catalog changes require `git pull` of this repo. npm installs update through `@latest`. ## Runtime Plugin Changes @@ -198,9 +192,8 @@ Align runtime with CI-first model (no local command-code required): 1. **Bundled `models.json`** from the installed plugin (npm package or `file://` checkout). Default. Always available if the install is intact. 2. Last-good cache file (identity spec) — only if bundled file is missing/corrupt. 3. Optional local `command-code` override when `commandCodePackagePath` or `COMMANDCODE_PACKAGE_PATH` is set — maintainers only. -4. Optional Provider API merge for context lengths (existing). -Local `command-code` is not scraped at startup unless the override is set. CI keeps the committed bundle current. +Runtime never calls the provider API or fetches prices; freshness is delivered by the catalog pipeline (availability filtering happens in sync). Local `command-code` is not scraped at startup unless the override is set. CI keeps the committed bundle current. ### Startup behavior @@ -211,15 +204,13 @@ Local `command-code` is not scraped at startup unless the override is set. CI ke - report `degraded` if manifest status is `degraded` - Auth/connect registers regardless of catalog state. -### Version nudge (optional, non-blocking) +### Not shipped -Compare the **installed plugin npm version** to that package’s npm `latest` (not `command-code@latest`). If behind, one diagnostics hint: update the plugin package. - -Do not compare against `command-code` itself: a catalog-break means command-code moved and the plugin correctly did not ship — that must not look like “plugin update available”. `file://` installs skip the npm version nudge. +An optional update-available nudge (installed plugin version vs npm `latest`) was specified but never implemented; catalog freshness reaches users through plugin updates instead. ## Extraction Code Requirements (prerequisite) -Phase 1 from identity spec must land before CI automation is reliable: +Phase 1 from the identity spec landed first; CI automation depends on it: 1. Split `loadCatalogFromBundle`: model required, cost optional. 2. `loadCatalogFromLocalCommandCode` must not return null on cost-only failure. @@ -254,16 +245,17 @@ Commit **extracted snapshots** (model JSON + expected counts/errors), not the pr | Secret | Purpose | |---|---| -| `NPM_TOKEN` | publish plugin package (`brainervirus`) | -| `GITHUB_TOKEN` | commit, release, issues | -| `CATALOG_PUSH_TOKEN` | optional; only if branch protection blocks `GITHUB_TOKEN` | +| `NPMJS` | npm **Automation** token, mapped to `NPM_TOKEN` and `NODE_AUTH_TOKEN`; publishes the plugin package (`brainervirus`) | +| `RELEASE_SYNC_TOKEN` | PAT; opens bot PRs so CI runs on the branch, and pushes the post-release sync branch | +| `CATALOG_PUSH_TOKEN` | optional fallback for `RELEASE_SYNC_TOKEN` | +| `GITHUB_TOKEN` | workflow default; alone it cannot start CI on bot-opened PRs | -If `main` is protected against `GITHUB_TOKEN`, set `CATALOG_PUSH_TOKEN`. One push path only. +One PR path only. ## Acceptance Criteria - User can run OpenCode with **no** global/local `command-code` install and get current models from the installed plugin (npm package, or a `file://` checkout that has been synced). -- Within 6 hours of a new `command-code` npm release, CI either commits a catalog update (and publishes a plugin patch if `NPM_TOKEN` is set) or opens/updates a `catalog-break` issue. +- Within 6 hours of a new `command-code` npm release, CI either opens a `fix(catalog)` PR (a patch publishes after it auto-merges) or opens/updates a `catalog-break` issue. - Cost-only CLI regressions (like 1.38) still ship a catalog. Costs come from official docs, then free SKUs ($0), then models.dev; `degraded` only if models remain unmatched. - Successful sync never requires local `bun run sync` from the user. - Failed model extraction never publishes a misleading npm release. @@ -284,7 +276,7 @@ Same sequence as the identity spec. Phase 1 (A) must refresh `models.json` befor 1. `manifest.json` schema + writer in `scripts/sync-models.ts`. 2. Official-docs cost parser (`/models`, then pricing-limits page). -3. `.github/workflows/catalog-sync.yml` (extract, cost waterfall, commit, issues). Skip npm publish when `NPM_TOKEN` unset. +3. `.github/workflows/catalog-sync.yml` (extract, availability filter, cost waterfall, `fix(catalog)` PR + auto-merge, issues). Publishing is owned by `release.yml` after merge. 4. Issue template + opener; auto-close `catalog-break` on later success. ### Phase C — runtime diagnostics from manifest @@ -294,10 +286,10 @@ Same sequence as the identity spec. Phase 1 (A) must refresh `models.json` befor ### Phase D — npm publish wiring -1. Rename package to `@brainervirus/commandcode-go-opencode-provider`. +1. Rename package to `@brainervirus/commandcode-go-opencode-provider` (shipped at 0.5.0; renamed to `@brainervirus/opencode-commandcode` at 0.6.0). 2. Store `NPM_TOKEN` (npm user `brainervirus`) as a GitHub Actions secret; `publishConfig.access: public`. 3. Enable publish + GitHub Release in the workflow. -4. Document `"plugin": ["@brainervirus/commandcode-go-opencode-provider@latest"]` vs pin. +4. Document `"plugin": ["@brainervirus/opencode-commandcode@latest"]` vs pin. ## Plan decomposition @@ -305,8 +297,8 @@ When writing implementation plans, split so each plan ships something usable: 1. **Unblock** — identity Phase 1 / CI Phase A (OpenCode works on current Command Code without the CLI). 2. **Watch + notify** — CI Phases B–C (cron, issues; works without npm). -3. **Identity** — identity Phases 2–3 (favorites, reasoning flags). Independent of CI after the bundle exists. -4. **Publish** — CI Phase D (`@brainervirus/commandcode-go-opencode-provider` + `NPM_TOKEN` on the GitHub repo). Local `npm whoami` does not publish from Actions. +3. **Identity** — reasoning flags shipped; favorites/alias migration and `(new)` badges did not (see identity spec non-goals). Independent of CI after the bundle exists. +4. **Publish** — CI Phase D (`@brainervirus/commandcode-go-opencode-provider` + `NPMJS` secret on the GitHub repo). Local `npm whoami` does not publish from Actions. The package was renamed to `@brainervirus/opencode-commandcode` at 0.6.0. Do not block Plan 1 on npm publishing. @@ -314,11 +306,10 @@ Do not block Plan 1 on npm publishing. | Risk | Mitigation | |---|---| -| CI auto-commit noise | skip extraction when tarball version unchanged | -| npm publish failure after commit | next cron retries publish for unpublished `package.json` version; tag/Release only after publish succeeds | +| Bot PR noise | skip extraction when tarball version unchanged | +| npm publish failure after merge | release job retries unpublished plugin versions; tag/Release only after publish succeeds | | Extraction anchors break silently | relative model-count floor + catalog-break issue | | User on `file://` | CI cannot update that checkout; README says git pull or switch to npm | -| Wrong “update available” hint | nudge compares plugin npm version, not `command-code@latest` | ## Related Specs diff --git a/docs/specs/2026-09-20-catalog-freshness.md b/docs/specs/2026-09-20-catalog-freshness.md new file mode 100644 index 0000000..3fb0f22 --- /dev/null +++ b/docs/specs/2026-09-20-catalog-freshness.md @@ -0,0 +1,195 @@ +# End-to-End Command Code Catalog Freshness + +Status: provider steps shipped (0.7.72, 2026-09-20); upstream OpenCode refresh pending + +## Goal + +OpenCode must show the current callable Command Code models after routine upstream releases, without requiring users to clear package caches or discover retired models by failed requests. + +Freshness has three independent boundaries: + +1. Command Code must identify which extracted models are currently callable. +2. This provider must publish only that callable catalog with internally consistent metadata. +3. OpenCode must refresh mutable plugin package references without discarding a working cache. + +## Current Failure + +The provider extracted 78 entries from `command-code@1.58.1`, while the public Command Code models endpoint listed 71 callable IDs. The seven omitted entries include statically hidden promotions, a dynamically hidden promotion, and an unreleased model. Because extraction dropped Command Code's `hidden` state, those entries were shipped and displayed as selectable OpenCode models. + +Separately, OpenCode can retain an older installation of `@brainervirus/opencode-commandcode@latest`. A current `models.json` therefore does not guarantee a current catalog on a user's machine. + +The release pipeline also updated `manifest.json` after npm publication. Consequently, the manifest inside a newly published tarball could report the previous plugin version even though the repository was corrected by a later synchronization PR. + +Provider-side resolution (2026-09-20): the availability filter shipped in `fix(catalog): exclude unavailable models` and the packed-manifest alignment shipped in `fix(release): align published manifest version`. The OpenCode-side mutable-package refresh (step 3 below) is still pending upstream. + +## Decisions + +### Callable model authority + +The public `GET https://api.commandcode.ai/provider/v1/models` response is the availability authority at synchronization time. The extractor output, combining metadata read from the Command Code CLI bundle with repository-maintained hardcoded entries for known bundle gaps, remains the metadata authority for fields that the endpoint does not provide. + +The publishable catalog is the exact-ID intersection of: + +- candidate entries produced from the selected Command Code CLI bundle and maintained hardcoded entries; and +- IDs in the availability response's `data` array. + +Matching is case-sensitive and exact. Display names and suffixes must not be used to infer availability. + +API-only IDs are not synthesized because they lack the required provider metadata. They enter the published catalog only after the extractor produces a matching candidate. + +An extracted model absent from the availability response is unavailable. It must: + +- be omitted from `models.json`; +- be omitted from generated OpenCode provider configuration, including `--update-global` output; and +- be recorded in `manifest.json` review metadata as an excluded ID with reason `not-listed-by-provider-api`. + +Unavailable entries are not emitted with OpenCode `status: "deprecated"`. OpenCode hides deprecated models from its selector, but retaining the model definition still permits persisted or direct IDs to resolve and attempt a request against an unavailable endpoint. + +Removing a model from the provider map must not edit OpenCode's favorites, recents, variants, or other local state. Existing unresolved state remains OpenCode-owned and can resolve again if the exact ID returns in a later catalog. + +### Safe synchronization + +Availability filtering occurs in CI and explicit maintainer synchronization, never during plugin startup. + +Before any generated artifact is written, synchronization must validate that: + +- the request succeeded; +- the response is an object with `object: "list"` and a non-empty `data` array; +- every retained item has a non-empty string `id`; +- IDs are unique; and +- the filtered model count passes the existing floor: `max(20, floor(lastSuccessfulModelCount * 0.5))`. + +Network failure, invalid JSON, invalid shape, duplicate IDs, an empty list, or a count below the floor is a catalog-break failure. The run must not modify `models.json`, `_version.txt`, or `manifest.json`, must not open a catalog PR, and must not publish a package. CI uses the existing `catalog-break` issue path. + +The previous committed artifacts remain the last-good catalog; this design adds no runtime availability call and no second cache (the runtime last-good cache from the identity spec is unchanged). + +Cost and modality enrichment runs only for the filtered catalog. `manifest.modelCount`, `reasoningModelCount`, and cost-source counts describe published models, not excluded candidates. + +`manifest.review.unavailable` is additive to schema version 1: + +```json +{ + "review": { + "unavailable": [ + { + "id": "minimax/minimax-m3-free", + "reason": "not-listed-by-provider-api" + } + ] + } +} +``` + +The unavailable list is sorted by ID so repeated synchronization is deterministic. + +### Mutable OpenCode plugin packages + +The upstream OpenCode implementation will rework pull request [anomalyco/opencode#49485](https://github.com/anomalyco/opencode/pull/49485). + +For npm plugin references: + +- exact versions are immutable and use the cache without refresh; +- bare package names, dist-tags such as `@latest`, and version ranges are mutable; +- a mutable reference with no cached installation blocks on installation; a failure reports the normal plugin installation error and leaves no partial cache; +- a mutable reference with a cached installation loads that cache immediately and starts at most one background refresh per OpenCode process; +- a successful background refresh becomes active on the next OpenCode restart; plugins are not hot-swapped in a running process; +- a failed background refresh leaves the cached installation untouched and does not block startup; and +- equivalent bare and explicit-latest references share one canonical cache location. + +Concurrent refresh attempts for the same canonical package are deduplicated. Installation remains atomic so interruption cannot replace a working cache with a partial package. + +Git, file, and workspace plugin references are outside this behavior change. + +### Published manifest version + +The `manifest.pluginVersion` embedded in an npm tarball must equal that tarball's `package.json` version. + +Semantic Release must set the manifest version from `nextRelease.version` during its prepare phase, before `@semantic-release/npm` packs and publishes. The existing post-release synchronization PR remains responsible for committing generated release versions back to protected `main`. + +Release verification must inspect the packed artifact and fail before publication when the two versions differ. + +## Runtime Contract + +The installed plugin reads only its bundled `models.json` and `manifest.json` for normal model registration. It does not call the availability endpoint, npm registry, or GitHub to decide which models to expose. + +This preserves deterministic startup and offline use. Freshness is delivered by catalog automation plus OpenCode's mutable-package refresh behavior. + +## Non-Goals + +- Evaluating minified `hidden` getters at plugin runtime. +- Probing every model with a billable inference request. +- Fuzzy or case-insensitive availability matching. +- Hot-reloading plugin code in a running OpenCode process. +- Deleting unresolved user model state. +- Changing OpenCode's deprecated-model semantics. +- Cleaning local duplicate plugin configuration, package-manager settings, or credentials. + +## Acceptance Criteria + +### Provider synchronization + +- A fixture with three CLI candidates and two API IDs writes exactly the two exact-ID matches. +- API-absent candidates appear only in sorted `manifest.review.unavailable` entries with reason `not-listed-by-provider-api`. +- Static or dynamic Command Code hiding requires no special parser handling when the hidden ID is absent from the API. +- An unavailable, malformed, empty, duplicate-ID, or below-floor response leaves all generated artifacts byte-for-byte unchanged and exits non-zero. +- Cost, reasoning, and manifest counts are computed from the filtered catalog. +- `bun run check` passes after generated artifacts are refreshed. + +### OpenCode package refresh + +- A warm mutable cache starts OpenCode without waiting for the registry. +- A cold mutable install failure reports an installation error and leaves no cache entry. +- One background refresh is attempted per canonical mutable package per process. +- Successful refresh output is used after restart, not during the current process. +- Offline or failed refresh preserves and continues using the prior cache. +- Exact-version references perform no background refresh. +- Bare and `@latest` references cannot maintain divergent cache roots. + +### Release metadata + +- A packed release candidate has equal versions in `package.json` and `manifest.json`. +- A mismatch fails before `npm publish`. +- The post-release synchronization PR remains non-releasing under the repository's `chore` release rule. + +### End-to-end + +From a clean OpenCode cache, installing the mutable Command Code plugin and restarting after a successful refresh exposes every exact-ID match between extractor candidates and the current API list, including newly added matches, and exposes none of the API-absent retired or unreleased entries. + +## Coordination Plan + +1. **Provider availability filter** + - Owner: provider maintainer. + - Files: `scripts/sync-models.ts`, `src/catalog.ts`, `src/manifest.ts`, focused unit fixtures/tests, then generated catalog artifacts. + - Dependency: none. + - Evidence: focused availability and manifest tests, a forced remote sync, unchanged-artifact failure tests, then `bun run check`. + - Commit: `fix(catalog): exclude unavailable models`. + - Status: shipped 2026-09-20. +2. **Published manifest alignment** + - Owner: provider maintainer. + - Files: Semantic Release configuration, release preparation script, release-candidate verification, and focused unit tests. + - Dependency: none; may proceed in the same PR after step 1 as a separate commit. + - Evidence: packed-artifact equality and mismatch rejection, then `bun run check`. + - Commit: `fix(release): align published manifest version`. + - Status: shipped 2026-09-20. +3. **Mutable plugin refresh** + - Owner: OpenCode contributor. + - Files: the package installation/cache path and tests in the upstream OpenCode repository. + - Dependency: rework pull request `#49485`; independent of provider steps 1 and 2. + - Evidence: cold, warm, offline, exact-version, canonical-cache, deduplication, and next-restart tests in upstream CI. + - Commit: follow OpenCode repository convention. + - Status: pending upstream. +4. **Rollout verification** + - Owner: provider maintainer. + - Dependency: provider release and an OpenCode build containing step 3. + - Evidence: compare the installed tarball manifest to its package version, launch from a clean cache, restart after refresh, and compare displayed provider IDs with the public availability response. + - Commit: none unless verification finds a defect. + - Status: pending upstream. + +Provider steps 1 and 2 shipped on 2026-09-20 before the upstream change. Until OpenCode releases step 3, users on stale mutable caches may still need one manual cache refresh; that temporary operational workaround is not part of the target behavior. + +## References + +- [OpenCode pull request #49485](https://github.com/anomalyco/opencode/pull/49485) +- [Command Code public models endpoint](https://api.commandcode.ai/provider/v1/models) +- [Stable model identity specification](./2026-08-19-stable-model-identity.md) +- [CI catalog automation specification](./2026-08-28-ci-catalog-automation.md)