diff --git a/CHANGELOG.md b/CHANGELOG.md index d88f2c6..d6d6c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - GitHub flow: PR-gated `main`, CI matrix (`check (test|typecheck|pack)`), semantic-release on merge, catalog updates as PRs. +- Release notes open with a catalog summary table and collapsed lists of fallback / unpriced models. - `manifest.json` describing the bundled catalog (status, cost sources, command-code version). - First publish of `@brainervirus/commandcode-go-opencode-provider`. - Last-good catalog cache and silent `startup.json` diagnostics under the plugin state dir. diff --git a/release.config.cjs b/release.config.cjs index 2352ab6..a2606f9 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -2,6 +2,7 @@ module.exports = { branches: ["main"], plugins: [ "@semantic-release/commit-analyzer", + "./scripts/semantic-release-catalog-notes.cjs", "@semantic-release/release-notes-generator", "@semantic-release/npm", "@semantic-release/github", diff --git a/scripts/catalog-release-notes.ts b/scripts/catalog-release-notes.ts new file mode 100644 index 0000000..b24bb1e --- /dev/null +++ b/scripts/catalog-release-notes.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env bun +import { readFileSync } from "fs" +import { join } from "path" +import { FALLBACK_COSTS } from "../src/catalog.js" +import { catalogReleaseNotesFromFiles } from "../src/catalog-release-notes.js" +import type { CatalogManifest } from "../src/manifest.js" +import type { PricedModel } from "../src/catalog-release-notes.js" + +const ROOT = join(import.meta.dir, "..") +const manifest = JSON.parse(readFileSync(join(ROOT, "manifest.json"), "utf-8")) as CatalogManifest +const models = JSON.parse(readFileSync(join(ROOT, "models.json"), "utf-8")) as PricedModel[] +process.stdout.write( + catalogReleaseNotesFromFiles({ + manifest, + models, + fallbackCosts: FALLBACK_COSTS, + }), +) diff --git a/scripts/semantic-release-catalog-notes.cjs b/scripts/semantic-release-catalog-notes.cjs new file mode 100644 index 0000000..4d5134a --- /dev/null +++ b/scripts/semantic-release-catalog-notes.cjs @@ -0,0 +1,10 @@ +const { execFileSync } = require("node:child_process") +const { join } = require("node:path") + +module.exports = { + generateNotes() { + return execFileSync("bun", [join(__dirname, "catalog-release-notes.ts")], { + encoding: "utf8", + }) + }, +} diff --git a/src/catalog-release-notes.ts b/src/catalog-release-notes.ts new file mode 100644 index 0000000..67fc881 --- /dev/null +++ b/src/catalog-release-notes.ts @@ -0,0 +1,152 @@ +import type { CatalogManifest, CatalogStatus, CostSources } from "./manifest.js" + +export type PricedModel = { + id: string + name: string + cost: { input: number; output: number } +} + +export type CatalogReleaseInput = { + pluginVersion: string + commandCodeVersion: string + modelCount: number + reasoningModelCount: number + status: CatalogStatus + costSources: CostSources + models: PricedModel[] + fallbackCosts: Record +} + +const DEFAULT_COST = { input: 0.5, output: 2 } + +export function partitionPrices( + models: PricedModel[], + fallbackCosts: Record, +): { official: PricedModel[]; fallback: PricedModel[]; unmatched: PricedModel[] } { + const official: PricedModel[] = [] + const fallback: PricedModel[] = [] + const unmatched: PricedModel[] = [] + for (const model of models) { + const known = fallbackCosts[model.id] + if (known && known.input === model.cost.input && known.output === model.cost.output) { + fallback.push(model) + } else if (model.cost.input === DEFAULT_COST.input && model.cost.output === DEFAULT_COST.output) { + unmatched.push(model) + } else { + official.push(model) + } + } + return { official, fallback, unmatched } +} + +function n(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}` +} + +function headline(status: CatalogStatus): string { + if (status === "healthy") { + return "**Catalog is complete.** All listed prices come from Command Code or official docs." + } + if (status === "broken") { + return "**Do not use this catalog.** Model extraction failed; OpenCode would not get a current model list." + } + return "**Safe to use.** Every model is listed. Some prices are estimates — expand the sections below to review them." +} + +function priceSummary(sources: CostSources): string { + const parts: string[] = [] + if (sources.cli > 0) parts.push(`${sources.cli} CLI`) + if (sources.officialDocs > 0) parts.push(`${sources.officialDocs} official docs`) + if (sources.thirdParty > 0) parts.push(`${sources.thirdParty} third-party`) + if (sources.fallback > 0) parts.push(`${sources.fallback} known fallback${sources.fallback === 1 ? "" : "s"}`) + if (sources.unmatched > 0) parts.push(`${sources.unmatched} no listed price`) + return parts.join(" · ") || "none" +} + +function money(n: number): string { + return `$${n}` +} + +function modelTable(models: PricedModel[]): string { + const rows = models + .map((m) => `| ${m.name} | \`${m.id}\` | ${money(m.cost.input)} / ${money(m.cost.output)} |`) + .join("\n") + return `| Model | Id | In / out per 1M |\n| --- | --- | --- |\n${rows}` +} + +function details(summary: string, body: string): string { + return `
\n${summary}\n\n${body}\n\n
` +} + +export function renderCatalogReleaseNotes(input: CatalogReleaseInput): string { + const { fallback, unmatched } = partitionPrices(input.models, input.fallbackCosts) + const sections = [ + headline(input.status), + "", + "| | |", + "| --- | --- |", + `| Plugin | ${input.pluginVersion} |`, + `| Command Code | ${input.commandCodeVersion} |`, + `| Models | ${input.modelCount} (${input.reasoningModelCount} with reasoning) |`, + `| Prices | ${priceSummary(input.costSources)} |`, + ] + + if (fallback.length > 0) { + sections.push( + "", + details( + n(fallback.length, "model with a known fallback price", "models with known fallback prices"), + modelTable(fallback), + ), + ) + } + if (unmatched.length > 0) { + sections.push( + "", + details( + n(unmatched.length, "model with no official price ($0.50 / $2)", "models with no official price ($0.50 / $2)"), + modelTable(unmatched), + ), + ) + } + + sections.push( + "", + details( + "Machine-readable catalog", + "```json\n" + + JSON.stringify( + { + status: input.status, + pluginVersion: input.pluginVersion, + commandCodeVersion: input.commandCodeVersion, + modelCount: input.modelCount, + reasoningModelCount: input.reasoningModelCount, + costSources: input.costSources, + }, + null, + 2, + ) + + "\n```", + ), + ) + + return sections.join("\n") + "\n" +} + +export function catalogReleaseNotesFromFiles(input: { + manifest: CatalogManifest + models: PricedModel[] + fallbackCosts: Record +}): string { + return renderCatalogReleaseNotes({ + pluginVersion: input.manifest.pluginVersion, + commandCodeVersion: input.manifest.commandCodeVersion, + modelCount: input.manifest.modelCount, + reasoningModelCount: input.manifest.reasoningModelCount, + status: input.manifest.status, + costSources: input.manifest.costSources, + models: input.models, + fallbackCosts: input.fallbackCosts, + }) +} diff --git a/tests/unit/catalog-release-notes.test.ts b/tests/unit/catalog-release-notes.test.ts new file mode 100644 index 0000000..d483aee --- /dev/null +++ b/tests/unit/catalog-release-notes.test.ts @@ -0,0 +1,89 @@ +import { expect, test, describe } from "bun:test" +import { renderCatalogReleaseNotes, type CatalogReleaseInput } from "../../src/catalog-release-notes.ts" + +const fallbackCosts = { + "google/gemini-3.5-flash": { input: 1.5, output: 9 }, +} + +function input(partial: Partial = {}): CatalogReleaseInput { + return { + pluginVersion: "0.5.0", + commandCodeVersion: "1.38.1", + modelCount: 3, + reasoningModelCount: 2, + status: "degraded", + costSources: { cli: 0, officialDocs: 1, thirdParty: 0, fallback: 1, unmatched: 1 }, + models: [ + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", cost: { input: 3, output: 15 } }, + { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash", cost: { input: 1.5, output: 9 } }, + { id: "tencent/Hy3", name: "Tencent Hy3 (Free)", cost: { input: 0.5, output: 2 } }, + ], + fallbackCosts, + ...partial, + } +} + +describe("renderCatalogReleaseNotes", () => { + test("leads with a usable headline and hides the raw status word", () => { + const md = renderCatalogReleaseNotes(input()) + expect(md).toContain("Safe to use") + expect(md).not.toMatch(/^#.*degraded/m) + expect(md.startsWith("degraded")).toBe(false) + }) + + test("puts counts in a compact table", () => { + const md = renderCatalogReleaseNotes(input()) + expect(md).toContain("| Command Code | 1.38.1 |") + expect(md).toContain("| Models | 3 (2 with reasoning) |") + expect(md).toContain("1 official docs") + expect(md).toContain("1 known fallback") + expect(md).toContain("1 no listed price") + }) + + test("collapses fallback and unmatched model lists", () => { + const md = renderCatalogReleaseNotes(input()) + expect(md).toContain("
") + expect(md).toContain("1 model with a known fallback price") + expect(md).toContain("1 model with no official price ($0.50 / $2)") + expect(md).toContain("Gemini 3.5 Flash") + expect(md).toContain("Tencent Hy3 (Free)") + expect(md).toContain("tencent/Hy3") + }) + + test("healthy catalog skips the extra lists", () => { + const md = renderCatalogReleaseNotes( + input({ + status: "healthy", + costSources: { cli: 0, officialDocs: 3, thirdParty: 0, fallback: 0, unmatched: 0 }, + models: [ + { id: "a", name: "A", cost: { input: 1, output: 2 } }, + { id: "b", name: "B", cost: { input: 1, output: 2 } }, + { id: "c", name: "C", cost: { input: 1, output: 2 } }, + ], + }), + ) + expect(md).toContain("All listed prices come from Command Code or official docs") + expect(md).not.toContain("known fallback") + expect(md).not.toContain("no official price") + }) + + test("broken catalog is explicit", () => { + const md = renderCatalogReleaseNotes( + input({ + status: "broken", + modelCount: 0, + reasoningModelCount: 0, + models: [], + costSources: { cli: 0, officialDocs: 0, thirdParty: 0, fallback: 0, unmatched: 0 }, + }), + ) + expect(md).toContain("Do not use this catalog") + }) + + test("includes a collapsed machine-readable block for agents", () => { + const md = renderCatalogReleaseNotes(input()) + expect(md).toContain("Machine-readable catalog") + expect(md).toContain('"status": "degraded"') + expect(md).toContain('"unmatched": 1') + }) +}) diff --git a/tests/unit/release-workflow.test.ts b/tests/unit/release-workflow.test.ts index ce39663..867203a 100644 --- a/tests/unit/release-workflow.test.ts +++ b/tests/unit/release-workflow.test.ts @@ -109,8 +109,12 @@ describe("release.config.cjs", () => { test("publishes the root package then GitHub Release", () => { const cfg = read("release.config.cjs") expect(cfg).toContain("@semantic-release/commit-analyzer") + expect(cfg).toContain("./scripts/semantic-release-catalog-notes.cjs") expect(cfg).toContain("@semantic-release/npm") expect(cfg).toContain("@semantic-release/github") + expect(cfg.indexOf("semantic-release-catalog-notes.cjs")).toBeLessThan( + cfg.indexOf("@semantic-release/release-notes-generator"), + ) expect(cfg.indexOf("@semantic-release/npm")).toBeLessThan(cfg.indexOf("@semantic-release/github")) }) })