diff --git a/AGENTS.md b/AGENTS.md index c39e533..828f2a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ npm run sync-models # regenerate provider.commandcode.models from the live catal ``` There is no unit-test suite. `scripts/smoke.mjs` is the end-to-end check. `scripts/sync-models.mjs` -is the model-catalog generator (needs `dist/` first; writes the opencode config unless `--out`/`--dry-run`). +is the interactive model picker (needs `dist/` first; needs a TTY; writes the global opencode config). After any change under `src/`, run `npm run typecheck`, then `npm run build`, then `npm run smoke`. A change is not done until it typechecks and the smoke test passes. diff --git a/README.md b/README.md index 946b820..0a493ff 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,21 @@ to it, but opencode builds `/models` and the TUI picker from this map and never opencode reads the model list from `provider.commandcode.models`; a custom provider cannot register dynamic discovery. `scripts/sync-models.mjs` fetches the live catalog from -`GET /provider/v1/models` and writes the full map into your opencode config. +`GET /provider/v1/models` and opens an interactive checkbox picker (nothing pre-checked). +Only the selected models are vision-probed and written into your global opencode config — +deselected ids are removed from it. ```powershell npm run build # the script imports the compiled provider -npm run sync-models # fetch catalog, probe vision, merge into ~/.config/opencode/opencode.json +npm run sync-models # fetch catalog, pick models, probe vision, write ~/.config/opencode/opencode.json ``` +Keys: `↑/↓` move (one model per step), `space` toggles, `Ctrl-A` selects all, `Ctrl-U` clears, +typing filters, `Esc` clears the filter (empty filter aborts), `enter` confirms, `Ctrl-C` aborts +without writing. Each row shows the model id and name plus a second line with its context window +(`ctx 1M (1000000)`); the catalog carries no pricing fields, so no cost is shown. Needs an +interactive terminal. + Then restart opencode and confirm: ```powershell @@ -108,21 +116,11 @@ Mapping and behaviour: `qwen…vl`, …). Probing is best-effort: upstream is nondeterministic and a re-run may flip a borderline model. - Generated fields (`name`, `limit`, `reasoning`, `attachment`, `modalities`) overwrite existing - values. Other fields (`variants`, `options`, `cost`, …) and ids not in the catalog are preserved. - -Options: - -```powershell -node scripts/sync-models.mjs --dry-run # print the merged map, write nothing -node scripts/sync-models.mjs --out models.json # write only the models fragment -node scripts/sync-models.mjs --no-probe # skip probing, use the heuristic (fast/offline) -node scripts/sync-models.mjs --concurrency 8 # probe parallelism (default 6) -node scripts/sync-models.mjs --no-preserve # snapshot only: drop user ids/extra fields -node scripts/sync-models.mjs --config # target a different config -``` + values. Other fields (`variants`, `options`, `cost`, …) on selected ids are preserved. + Unselected ids are removed from the map. The API key is read from `COMMANDCODE_API_KEY`, then `provider.commandcode.options.apiKey` in the -target config. +global config (`baseURL`/`ccVersion` are read from there too). ## Configuration options @@ -303,7 +301,7 @@ src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage src/constants.ts Defaults, header names, passthrough params, static config block. scripts/smoke.mjs Live end-to-end check against api.commandcode.ai. scripts/quota.mjs Print the live quota headlessly. -scripts/sync-models.mjs Generate provider.commandcode.models from the live catalog. +scripts/sync-models.mjs Pick models interactively and write provider.commandcode.models. ``` ### Request lifecycle diff --git a/scripts/sync-models.mjs b/scripts/sync-models.mjs index 9eaddd3..313e563 100644 --- a/scripts/sync-models.mjs +++ b/scripts/sync-models.mjs @@ -1,23 +1,28 @@ -// Generate the opencode `provider.commandcode.models` map from CommandCode's live catalog. +// Interactive model picker for opencode `provider.commandcode.models`. // // opencode builds `/models` from the configured model map; a custom `file://` provider is only // ever asked for `languageModel(id)`, never queried for discovery. So the catalog has to be baked -// into opencode.json. This script fetches it, probes each model for vision support, and merges the -// result into the target config. +// into opencode.json. This script fetches the live catalog, lets the user pick which models to +// enable with a builtins-only checkbox UI, probes the selection for vision support, and writes +// the result into the global opencode config. Deselecting a model removes it from the config. // -// Run: node scripts/sync-models.mjs [options] (build first: npm run build) +// Run: node scripts/sync-models.mjs (build first: npm run build) import { readFileSync, writeFileSync, renameSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import readline from "node:readline"; import { createCommandCode } from "../dist/index.js"; import { redact } from "../dist/redact.js"; import { DEFAULT_BASE_URL, DEFAULT_CC_VERSION, DEFAULT_MAX_TOKENS, MODELS_PATH } from "../dist/constants.js"; +const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json"); +const CONCURRENCY = 6; + // 64x64 solid red PNG, used only to provoke a vision-accepting vs vision-rejecting response. const PROBE_IMAGE = { type: "file", mediaType: "image/png", - data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeUlEQVR4nO3PQQkAMAzAwCqpf1ETMxF7HINABFzm7H7dcEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFj13PLIEAOXyUUwAAAABJRU5ErkJggg==", + data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeUlEQVR4nO3PQQkAMAzAwCqpf1ETMxF7HINABFzm7H7dcEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFj13PLIEAOXyUUwAAAABJRU5ErkJggg==", }; const VISION_ERROR = /image|vision|multimodal|modality|unsupported|not support|invalid.*content|does not support/i; @@ -26,60 +31,10 @@ const VISION_ERROR = /image|vision|multimodal|modality|unsupported|not support|i const NO_IMAGE = /(?:don'?t|do not|can'?t|cannot|unable to)\s+(?:see|view|detect|find|access|receive|open|process)|no\s+(?:image|picture|attachment|photo)|not\s+(?:see|receive|detect|attach)|image\s+(?:was\s+)?not\s+(?:attach|provid|receiv|includ)/i; -// Fallback when a probe is inconclusive or disabled. Ground truth is unavailable from the catalog, +// Fallback when a probe is inconclusive. Ground truth is unavailable from the catalog, // so this is only a best guess for ids that look like known vision families. const VISION_HEURISTIC = /claude|gpt-5|gemini|grok|qwen.*vl|vision|(?:^|[^a-z])vl(?:[^a-z]|$)|omni|multimodal/i; -function parseArgs(argv) { - const opts = { - config: join(homedir(), ".config", "opencode", "opencode.json"), - baseURL: undefined, - out: undefined, - dryRun: false, - probe: true, - concurrency: 6, - preserve: true, - }; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - const value = () => { - const next = argv[++i]; - if (next === undefined) throw new Error(`Missing value for ${arg}`); - return next; - }; - switch (arg) { - case "--config": opts.config = value(); break; - case "--base-url": opts.baseURL = value(); break; - case "--out": opts.out = value(); break; - case "--concurrency": opts.concurrency = Math.max(1, Number(value()) || 1); break; - case "--dry-run": opts.dryRun = true; break; - case "--no-probe": opts.probe = false; break; - case "--no-preserve": opts.preserve = false; break; - case "-h": - case "--help": - console.log( - [ - "Generate provider.commandcode.models from the CommandCode catalog.", - "", - "Usage: node scripts/sync-models.mjs [options]", - " --config opencode config to update (default ~/.config/opencode/opencode.json)", - " --base-url upstream origin (default from config or api.commandcode.ai)", - " --out write only the models fragment; do not touch the config", - " --dry-run print the merged map; write nothing", - " --no-probe skip vision probing; use the family heuristic", - " --concurrency vision probe parallelism (default 6)", - " --no-preserve snapshot only; drop user-added ids and extra fields", - ].join("\n"), - ); - process.exit(0); - break; - default: - throw new Error(`Unknown argument: ${arg}`); - } - } - return opts; -} - function readConfig(path) { const text = readFileSync(path, "utf8"); return { text, data: JSON.parse(text) }; @@ -197,26 +152,227 @@ function modelEntry(catalogModel, vision) { return entry; } -function mergeModels(existing, generated, preserve) { - const out = {}; - if (preserve) { - for (const [id, model] of Object.entries(existing ?? {})) out[id] = model; - } - for (const [id, entry] of Object.entries(generated)) { - out[id] = preserve ? { ...(existing?.[id] ?? {}), ...entry } : entry; - } - return out; -} - function writeAtomic(path, contents) { const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, contents); renameSync(tmp, path); } +// The catalog carries no pricing fields (id/name/context_length/supported_endpoints only), +// so the picker shows context with any opportunistically present pricing keys omitted. +function formatContext(value) { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return "unknown"; + if (n >= 1_000_000 && n % 1_000_000 === 0) return `${n / 1_000_000}M (${n})`; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M (${n})`; + if (n >= 1000 && n % 1000 === 0) return `${n / 1000}k (${n})`; + if (n >= 1000) return `${(n / 1000).toFixed(0)}k (${n})`; + return `${n}`; +} + +// Builtins-only checkbox picker (no dependencies, so the package stays runtime-dependency-free). +// Nothing is pre-checked; deselected ids are dropped by the caller. Resolves to the selected +// catalog indices, or null when the user aborts (Esc on empty filter / Ctrl-C). +function pickModels(catalog, existingIds) { + return new Promise((resolve) => { + const checked = new Set(); + let filter = ""; + let cursor = 0; + let scroll = 0; + let done = false; + + const columns = () => process.stdout.columns ?? 80; + // Two terminal lines per model (header + context detail); keep at least one model visible. + const pageSize = () => Math.max(1, Math.floor(Math.max(5, (process.stdout.rows ?? 24) - 7) / 2)); + + const filtered = () => { + if (!filter) return catalog.map((_, index) => index); + const needle = filter.toLowerCase(); + const out = []; + catalog.forEach((model, index) => { + if (`${model.id} ${model.name ?? ""}`.toLowerCase().includes(needle)) out.push(index); + }); + return out; + }; + + const clamp = (list) => { + if (list.length === 0) { + cursor = 0; + scroll = 0; + return; + } + cursor = Math.min(Math.max(0, cursor), list.length - 1); + const size = pageSize(); + if (cursor < scroll) scroll = cursor; + if (cursor >= scroll + size) scroll = cursor - size + 1; + }; + + const line = (text, code) => { + // Slice visible text first so ANSI codes never count toward the terminal + // width or get cut mid-sequence. NO_COLOR and dumb terminals get plain text. + const visible = String(text).slice(0, columns()); + if (code === undefined || process.env.NO_COLOR || process.env.TERM === "dumb") { + process.stdout.write(`${visible}\n`); + return; + } + process.stdout.write(`\x1b[${code}m${visible}\x1b[0m\n`); + }; + + const render = () => { + const list = filtered(); + clamp(list); + const size = pageSize(); + const view = list.slice(scroll, scroll + size); + process.stdout.write("\x1b[?25l\x1b[2J\x1b[H"); + line("Select CommandCode models (type to filter · space toggle · enter confirm)"); + line(`Catalog: ${catalog.length} models · Selected: ${checked.size} · Filter: ${filter}\u2588`); + line(""); + if (list.length === 0) { + line(" (no matches)"); + } + view.forEach((original, i) => { + const model = catalog[original]; + const active = scroll + i === cursor; + const selected = checked.has(original); + const mark = selected ? "x" : " "; + const current = existingIds.has(model.id) ? " · current" : ""; + // Selected rows render green (both lines); the cursor row renders bold on top. + const code = selected ? (active ? "1;32" : "32") : active ? "1" : undefined; + line(`${active ? "\u203a" : " "} [${mark}] ${model.id} \u2014 ${model.name ?? model.id}${current}`, code); + line(` ctx ${formatContext(model.context_length)}`, code); + }); + line(""); + line("\u2191/\u2193 navigate · space toggle · ctrl-a all · ctrl-u none · esc back/abort · enter confirm"); + }; + + const cleanup = () => { + if (done) return; + done = true; + process.stdout.removeListener("resize", render); + process.stdin.removeListener("keypress", onKey); + try { + process.stdin.setRawMode(false); + } catch { + // Non-TTY teardown; terminal state is already sane. + } + process.stdin.pause(); + process.stdout.write("\x1b[?25h"); + }; + + const finish = (value) => { + cleanup(); + resolve(value); + }; + + const toggleAll = (list, value) => { + for (const index of list) { + if (value) checked.add(index); + else checked.delete(index); + } + }; + + function onKey(str, key) { + if (done) return; + const list = filtered(); + if (key.ctrl && (key.name === "c" || key.name === "d")) { + finish(null); + return; + } + if (key.ctrl && key.name === "a") { + toggleAll(list, true); + render(); + return; + } + if (key.ctrl && key.name === "u") { + checked.clear(); + render(); + return; + } + switch (key.name) { + case "return": + finish([...checked].sort((a, b) => a - b)); + return; + case "escape": + // First Esc clears the filter; a second Esc on an empty filter aborts. + if (filter) { + filter = ""; + cursor = 0; + scroll = 0; + render(); + return; + } + finish(null); + return; + case "backspace": + if (filter) { + filter = filter.slice(0, -1); + cursor = 0; + scroll = 0; + render(); + } + return; + case "up": + cursor = list.length === 0 ? 0 : (cursor - 1 + list.length) % list.length; + render(); + return; + case "down": + cursor = list.length === 0 ? 0 : (cursor + 1) % list.length; + render(); + return; + case "space": { + const current = list[cursor]; + if (current !== undefined) { + if (checked.has(current)) checked.delete(current); + else checked.add(current); + } + render(); + return; + } + default: + break; + } + // All printable characters feed the filter — letter shortcuts would swallow + // filter input (e.g. typing "qwen" would abort on the "q"). Bulk actions live + // on Ctrl combos above; abort is Esc (empty filter) or Ctrl-C. + if (!key.ctrl && !key.meta && typeof str === "string" && str.length === 1) { + const printable = str >= " " && str <= "~"; + if (printable && str !== " ") { + filter += str; + cursor = 0; + scroll = 0; + render(); + } + } + } + + readline.emitKeypressEvents(process.stdin); + try { + process.stdin.setRawMode(true); + } catch { + resolve(null); + return; + } + process.stdin.resume(); + process.stdout.on("resize", render); + process.stdin.on("keypress", onKey); + render(); + }); +} + async function main() { - const opts = parseArgs(process.argv.slice(2)); - const { text, data: config } = readConfig(opts.config); + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error("Interactive picker needs a TTY; nothing was written."); + process.exit(1); + } + + let config; + let text; + try { + ({ text, data: config } = readConfig(CONFIG_PATH)); + } catch (error) { + console.error(`Cannot read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } const key = resolveKey(config, process.env); if (!key) { @@ -224,7 +380,7 @@ async function main() { process.exit(1); } - const baseURL = (opts.baseURL ?? config?.provider?.commandcode?.options?.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + const baseURL = (config?.provider?.commandcode?.options?.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); const ccVersion = config?.provider?.commandcode?.options?.ccVersion ?? DEFAULT_CC_VERSION; console.log(`Fetching catalog: ${baseURL}${MODELS_PATH}`); @@ -235,24 +391,40 @@ async function main() { console.error(`Failed to fetch catalog: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } + if (catalog.length === 0) { + console.error("Catalog is empty; config unchanged."); + process.exit(1); + } console.log(`Catalog: ${catalog.length} models`); - const provider = createCommandCode({ name: "commandcode", apiKey: key, baseURL, ccVersion, maxRetries: 1 }); + const existing = config?.provider?.commandcode?.models ?? {}; + const existingIds = new Set(Object.keys(existing)); - let probeResults = []; - if (opts.probe) { - console.log(`Probing vision (${catalog.length} models, concurrency ${opts.concurrency})...`); - probeResults = await mapPool(catalog, opts.concurrency, async (model, index) => { - const outcome = await probeVisionConfirmed(provider, model.id); - const tag = outcome.vision === true ? "vision" : outcome.vision === false ? "no-vision" : "unknown"; - console.log(` [${index + 1}/${catalog.length}] ${model.id}: ${tag} (${outcome.reason})`); - return outcome; - }); + const picked = await pickModels(catalog, existingIds); + if (picked === null) { + console.log("Aborted \u2014 config unchanged."); + return; + } + if (picked.length === 0) { + console.log("No models selected \u2014 config unchanged."); + return; } - const generated = {}; + const selected = picked.map((index) => catalog[index]).filter(Boolean); + console.log(`Probing vision (${selected.length} models, concurrency ${CONCURRENCY})...`); + const provider = createCommandCode({ name: "commandcode", apiKey: key, baseURL, ccVersion, maxRetries: 1 }); + const probeResults = await mapPool(selected, CONCURRENCY, async (model, index) => { + const outcome = await probeVisionConfirmed(provider, model.id); + const tag = outcome.vision === true ? "vision" : outcome.vision === false ? "no-vision" : "unknown"; + console.log(` [${index + 1}/${selected.length}] ${model.id}: ${tag} (${outcome.reason})`); + return outcome; + }); + + // Deselect = remove: the final map holds exactly the picked ids. Extra user fields on + // picked ids are preserved; unpicked ids are dropped. + const merged = {}; const stats = { probed: 0, vision: 0, noVision: 0, unknown: 0, heuristic: 0 }; - catalog.forEach((model, index) => { + selected.forEach((model, index) => { const outcome = probeResults[index]; let vision; if (outcome && outcome.vision !== undefined) { @@ -265,29 +437,10 @@ async function main() { stats.unknown++; if (vision) stats.heuristic++; } - generated[model.id] = modelEntry(model, vision); + merged[model.id] = { ...(existing[model.id] ?? {}), ...modelEntry(model, vision) }; }); - const existing = config?.provider?.commandcode?.models ?? {}; - const merged = mergeModels(existing, generated, opts.preserve); - const added = Object.keys(generated).filter((id) => !(id in existing)).length; - const preservedIds = Object.keys(existing).filter((id) => !(id in generated)).length; - - if (opts.out) { - writeAtomic(opts.out, `${JSON.stringify(generated, null, 2)}\n`); - console.log(`Wrote ${Object.keys(generated).length} models to ${opts.out}`); - return; - } - - if (opts.dryRun) { - console.log(JSON.stringify(merged, null, 2)); - console.log( - `\n[dry-run] generated=${Object.keys(generated).length} added=${added} ` + - `preserved-ids=${preservedIds} probed=${stats.probed} vision=${stats.vision} ` + - `no-vision=${stats.noVision} unknown=${stats.unknown} heuristic-vision=${stats.heuristic}`, - ); - return; - } + const removed = [...existingIds].filter((id) => !(id in merged)).length; config.provider ??= {}; config.provider.commandcode ??= {}; @@ -295,11 +448,11 @@ async function main() { const indent = detectIndent(text); const trailing = text.endsWith("\n") ? "\n" : ""; - writeAtomic(opts.config, `${JSON.stringify(config, null, indent)}${trailing}`); + writeAtomic(CONFIG_PATH, `${JSON.stringify(config, null, indent)}${trailing}`); console.log( - `Wrote ${Object.keys(merged).length} models to ${opts.config}\n` + - ` generated=${Object.keys(generated).length} added=${added} preserved-ids=${preservedIds}\n` + + `Wrote ${Object.keys(merged).length} models to ${CONFIG_PATH}\n` + + ` selected=${selected.length} removed=${removed}\n` + ` probed=${stats.probed} vision=${stats.vision} no-vision=${stats.noVision} ` + `unknown=${stats.unknown} heuristic-vision=${stats.heuristic}\n` + `Restart opencode, then run: opencode models commandcode`,