Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions release.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions scripts/catalog-release-notes.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
)
10 changes: 10 additions & 0 deletions scripts/semantic-release-catalog-notes.cjs
Original file line number Diff line number Diff line change
@@ -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",
})
},
}
152 changes: 152 additions & 0 deletions src/catalog-release-notes.ts
Original file line number Diff line number Diff line change
@@ -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<string, { input: number; output: number }>
}

const DEFAULT_COST = { input: 0.5, output: 2 }

export function partitionPrices(
models: PricedModel[],
fallbackCosts: Record<string, { input: number; output: number }>,
): { 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 `<details>\n<summary>${summary}</summary>\n\n${body}\n\n</details>`
}

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, { input: number; output: number }>
}): 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,
})
}
89 changes: 89 additions & 0 deletions tests/unit/catalog-release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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("<details>")
expect(md).toContain("<summary>1 model with a known fallback price</summary>")
expect(md).toContain("<summary>1 model with no official price ($0.50 / $2)</summary>")
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("<summary>Machine-readable catalog</summary>")
expect(md).toContain('"status": "degraded"')
expect(md).toContain('"unmatched": 1')
})
})
4 changes: 4 additions & 0 deletions tests/unit/release-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
})
})
Expand Down