Make sync-models an interactive model picker
This commit is contained in:
parent
7e77c3c6fe
commit
ec7f32feea
@ -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.
|
||||
|
||||
30
README.md
30
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 <path> # 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
|
||||
|
||||
@ -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 <path> opencode config to update (default ~/.config/opencode/opencode.json)",
|
||||
" --base-url <url> upstream origin (default from config or api.commandcode.ai)",
|
||||
" --out <file> 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 <n> 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`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user