Files
airship/scripts/gen-models.mjs
T
Nayan 78b48e7ca4 feat(cli): name the git problem before it bites
`airship doctor` had one `git repo` line that reported `isGitRepo` and nothing
else, so a machine with no git on PATH was told it was not in a repository.
That is now two checks, because they fail for different reasons and have
different fixes: whether git can run at all, and whether this directory is
somewhere it can usefully run.

The level follows the rule `checkAgents` already set — only the backend being
used can fail the run. Claude snapshots its own before-state through a pre-tool
hook and edits and undoes with no git at all, so a working Claude install must
not be reported as broken; codex and opencode reconstruct their baseline from
HEAD and cannot. `doctor` exits non-zero on any fail and is documented as
scriptable, which is what that distinction protects.

The launch banner's git warning becomes unconditional. It used to fire only for
the non-Claude backends, but whatever is wrong with git breaks Commit and
Create pull request on every one of them; the diff-baseline sentence is the
half that really is backend-specific.

`--debug` had no consumer but `reportError`'s stack print. It now also prints
every failed git invocation to stderr with its argv, exit status and full
stderr — the detail behind the one line a toast has room for. stderr, so
`--json` stays parseable, and `AIRSHIP_DEBUG=1` works without changing how the
daemon is launched, which matters when the person who needs the trace is not
the person who knows the flags.

`isGitRepo` leaves @airship/server's exports with its last consumer. It stays
in @airship/git as the cheap predicate `commitEdit` takes before paying for a
`gitStatus`; it is no longer what anything user-facing asks, because it answers
"false" to three different problems.

Also: gen-models.mjs spawned `node_modules/.bin/biome`, the POSIX shell
wrapper, with no shell and no `.CMD` fallback — the last un-forked spawn in the
repo. Only reachable through `make models:refresh`, so it never failed in CI.
2026-08-16 13:05:37 +05:30

308 lines
12 KiB
JavaScript

// Generates packages/protocol/src/models.ts — the seed model catalogue — from
// the models.dev registry.
//
// Why a seed exists at all: of the three harnesses, only two can enumerate
// their own models. Claude answers `query.supportedModels()` and OpenCode
// answers `client.config.providers()`, both live and both scoped to what the
// user is actually authenticated for. **Codex can enumerate nothing** — no CLI
// subcommand, no app-server RPC, no config file to read. Without this its list
// would be a constant somebody has to remember to edit on every OpenAI release,
// and the failure mode of forgetting is silent: the picker just stops offering
// the model you wanted.
//
// The seed also does two smaller jobs. It is what the menu paints *before* the
// live probe returns, so opening the picker is never a wait; and it is what the
// menu falls back to when a probe fails or there is no network.
//
// Usage:
// node scripts/gen-models.mjs # fetch and write
// node scripts/gen-models.mjs --check # verify without writing
//
// `--check` exists for local use — `make preflight` deliberately does NOT run
// it. Every other generated file in this repo derives from something committed
// beside it, so a drift gate can only fire when a human changed the input. This
// one derives from a remote file that changes whenever a vendor ships a model,
// so gating on it would make the gate go red on PRs that touched nothing and
// require network to pass. `reference/NEXT-STEPS.md` §7 describes what that
// costs: "the gate will fail on every single PR forever."
//
// Refresh is therefore a deliberate act — `make models:refresh` — reviewed as a
// diff, the way a lockfile bump is.
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
// `new URL(..., import.meta.url)` throughout, handed straight to the fs calls,
// which take a file URL. Same reasoning as scripts/gen-controls.mjs: nothing
// here converts one to a path string, which sidesteps the `/C:/…` and
// percent-encoding traps packages/overlay/scripts/check-css.mjs documents.
const CURATION = new URL("./models.curation.json", import.meta.url);
const OUT = new URL("../packages/protocol/src/models.ts", import.meta.url);
// The provider-agnostic file, 279 KB. `api.json` carries the same models keyed
// per provider with pricing attached and is 3.7 MB — thirteen times the bytes
// for a `cost` field no menu row renders. Switch only if a price hint is added.
const SOURCE = "https://models.dev/models.json";
/** models.dev id prefix → the harness whose group the model belongs in. */
const HARNESS = { "anthropic/": "claude", "openai/": "codex" };
/**
* `.bin/biome` is the POSIX shell wrapper, which Windows cannot execute. pnpm
* writes a `biome.CMD` beside it for exactly this, and spawning that needs a
* shell — a batch file is not an executable image. The arguments here are
* fixed and the generated source travels on stdin rather than argv, so there is
* nothing for a shell to mis-split.
*/
const WIN32 = process.platform === "win32";
const BIOME = new URL(
`../node_modules/.bin/biome${WIN32 ? ".CMD" : ""}`,
import.meta.url
);
function die(message) {
process.stderr.write(`gen-models: ${message}\n`);
process.exit(1);
}
/**
* Hand the rendered source to biome before it is written or compared.
*
* Not a nicety — it is what keeps `--check` honest. `make preflight` runs
* `ultracite fix` over the whole tree, so a generated file that is not already
* clean gets rewritten the moment anyone lints, and every subsequent `--check`
* then reports stale against a file nobody touched. Running it here means the
* generator and the linter cannot disagree, and the template is free to emit
* readable one-line rows without predicting where biome wraps.
*
* `check --write` rather than `format`: formatting alone leaves lint rules to
* fire later, which is the same staleness one step removed. Safe fixes only —
* `--unsafe` reflows prose (it "fixes" a JSDoc line starting with an asterisk
* by deleting the space, mangling the sentence), so anything it would touch is
* a template bug to fix here rather than to paper over.
*/
function format(source) {
const out = spawnSync(
fileURLToPath(BIOME),
["check", "--write", "--stdin-file-path=models.ts"],
{ encoding: "utf8", input: source, shell: WIN32 }
);
if (out.error || out.status !== 0) {
die(
`biome could not format the output: ${out.error?.message ?? out.stderr?.trim() ?? `exit ${out.status}`}`
);
}
return out.stdout;
}
/**
* Context window as a menu hint.
*
* `MenuItem.hint` renders right-aligned in a dimmed mono — "a shortcut, a size,
* a unit" — so it wants `1M`, not a sentence. This is the whole reason the seed
* carries metadata rather than bare ids: Claude's own `supportedModels()`
* returns a prose `description`, which is the wrong shape for that slot.
*/
function contextHint(limit) {
const n = limit?.context;
if (!n) {
return;
}
return n >= 1_000_000
? `${Math.round(n / 100_000) / 10}M`.replace(".0M", "M")
: `${Math.round(n / 1000)}K`;
}
/** Strip the provider prefix: `anthropic/claude-opus-5` → `claude-opus-5`. */
function bareId(id) {
return id.slice(id.indexOf("/") + 1);
}
/**
* The mechanical half of the filter.
*
* Everything decided here is a property models.dev states outright. Anything
* that needs judgement — that `gpt-realtime-2.1` passes `tool_call` and
* `reasoning` but is not a coding model — belongs in the deny list, where it is
* reviewable, rather than as a special case in this function.
*/
function candidates(models, curation) {
const deny = curation.deny.map((p) => new RegExp(p));
const out = [];
for (const [id, model] of Object.entries(models)) {
const prefix = Object.keys(HARNESS).find((p) => id.startsWith(p));
if (!(prefix && model.tool_call && model.reasoning)) {
continue;
}
if (!model.release_date || model.release_date < curation.since) {
continue;
}
if (deny.some((re) => re.test(id))) {
continue;
}
out.push({
date: model.release_date,
harness: HARNESS[prefix],
hint: contextHint(model.limit),
id,
label: model.name ?? bareId(id),
});
}
// Newest first, then by id so a same-day pair never reorders between runs.
// Determinism is the property the "run it twice, no diff" check rests on.
out.sort((a, b) => b.date.localeCompare(a.date) || a.id.localeCompare(b.id));
return out;
}
/**
* Candidates → the three harness groups.
*
* Claude and Codex take the bare id, which is what `--model` wants for each.
* OpenCode takes the `provider/model` form its adapter's `modelRefFor` splits
* on — a bare id there has no resolvable provider and gets dropped.
*
* OpenCode's group is seeded from the same two providers rather than from the
* whole registry. It resolves models.dev itself at startup and reports back
* only what the user is authenticated for, so anything richer here would be
* both duplicated work and a list of models that cannot be called. This is
* first paint, and the live probe replaces it wholesale.
*/
function group(all, curation) {
const seeded = { claude: [], codex: [], opencode: [] };
for (const harness of ["claude", "codex"]) {
const extra = curation.extra?.[harness] ?? [];
const derived = all
.filter((m) => m.harness === harness)
.slice(0, curation.limit)
.map((m) => ({ hint: m.hint, id: bareId(m.id), label: m.label }));
seeded[harness] = [...extra, ...derived];
}
seeded.opencode = all
.slice(0, curation.limit)
.map((m) => ({ hint: m.hint, id: m.id, label: m.label }));
return seeded;
}
function render(seeded, count) {
const rows = (models) =>
models
.map((m) => {
const hint = m.hint ? ` hint: ${JSON.stringify(m.hint)},` : "";
return ` {${hint} id: ${JSON.stringify(m.id)}, label: ${JSON.stringify(m.label)} },`;
})
.join("\n");
return `// AUTO-GENERATED from ${SOURCE} by scripts/gen-models.mjs.
// Do not edit by hand — edit scripts/models.curation.json and run
// \`make models:refresh\`. ${count} models across three harnesses.
/**
* The model list the picker paints before anything has been asked, and falls
* back to when a probe fails.
*
* This module is deliberately free of zod and of every other runtime import,
* and reaches the overlay through the \`./models\` export subpath alongside
* \`./tokens\`. That is what lets the browser bundle import the list as a value,
* rather than hand-copying it beside \`AGENTS\` in \`app.ts\`, at no cost — an
* import from the package's main entry would pull the validator in with it.
*
* (The overlay bundle does contain zod today regardless: \`app.ts\` takes
* \`modeToSurface\` and the surface constants from that main entry, which is
* enough to drag it along. This module simply does not add to that, and stays
* correct if those imports are ever moved.)
*
* It is a *seed*, never the truth. Claude and OpenCode both enumerate their own
* models at runtime and their answers win, because only they know what the user
* is signed in to. Codex can enumerate nothing at any layer, so for that
* harness this is the whole list — which is the reason the generator exists.
*/
export interface SeedModel {
/** Right-aligned dimmed hint on the row — a context window. */
hint?: string;
/** What goes on the wire. Bare for claude and codex; \`provider/model\` for
* opencode, whose \`modelRefFor\` drops an id it cannot attribute. */
id: string;
label: string;
}
/*
* Keys spelled out rather than imported as \`AgentKind\`. That type is a
* \`z.infer\`, so naming it here would tie this module to the zod-importing
* barrel for no gain — \`models.test.ts\` asserts these keys against
* \`AGENT_KINDS\` instead, which catches the drift the import would have.
*/
export const SEED_MODELS: Record<"claude" | "codex" | "opencode", SeedModel[]> =
{
claude: [
${rows(seeded.claude)}
],
codex: [
${rows(seeded.codex)}
],
opencode: [
${rows(seeded.opencode)}
],
};
`;
}
async function main() {
const curation = JSON.parse(readFileSync(CURATION, "utf8"));
const check = process.argv.includes("--check");
let models;
try {
const res = await fetch(SOURCE);
if (!res.ok) {
die(`${SOURCE} returned ${res.status}`);
}
models = await res.json();
} catch (err) {
die(`could not fetch ${SOURCE}: ${err.message}`);
}
const all = candidates(models, curation);
if (!all.length) {
// A registry reshuffle that silently emptied the file would take Codex's
// only model list with it, so this fails rather than writing the void.
die(
"no models survived the filter — check `since` and `deny` in models.curation.json"
);
}
const seeded = group(all, curation);
const count = Object.values(seeded).reduce((n, g) => n + g.length, 0);
const next = format(render(seeded, count));
// `\r\n` normalised on both sides: the repo is checked out with native line
// endings on Windows and this comparison is about content.
const same = (a, b) => a.replace(/\r\n/g, "\n") === b.replace(/\r\n/g, "\n");
if (check) {
let current = "";
try {
current = readFileSync(OUT, "utf8");
} catch {
die(
"packages/protocol/src/models.ts is missing — run `make models:refresh`."
);
}
if (!same(current, next)) {
die(
"packages/protocol/src/models.ts is stale — run `make models:refresh` and commit the result."
);
}
process.stdout.write("packages/protocol/src/models.ts is up to date\n");
return;
}
writeFileSync(OUT, next);
process.stdout.write(
`wrote packages/protocol/src/models.ts — ${seeded.claude.length} claude, ${seeded.codex.length} codex, ${seeded.opencode.length} opencode\n`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}