docks-kit 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Per-machine kit store at ~/.docks-kit/kit.db. This module is the only code
3
+ * that opens the database: it applies the schema migrations, imports the
4
+ * legacy ~/.docks-kit/state.json once, and serves the network lookup cache.
5
+ */
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
7
+ import { DatabaseSync } from "node:sqlite";
8
+
9
+ import { GENERATED_PACKAGE_VERSION } from "../generated/sotPayload";
10
+ import { p } from "./exec";
11
+
12
+ /**
13
+ * Append-only. Never edit or reorder a shipped entry. A release that changes the
14
+ * schema appends one SQL string. State tables (harness_selection, omp_session)
15
+ * get data-preserving migrations; cache_entry may be dropped and recreated
16
+ * because every row can be refetched.
17
+ */
18
+ const MIGRATIONS: ReadonlyArray<string> = [
19
+ `CREATE TABLE harness_selection (
20
+ harness TEXT PRIMARY KEY CHECK (harness IN ('claude','codex','agents','omp'))
21
+ ) STRICT, WITHOUT ROWID;
22
+ CREATE TABLE omp_session (
23
+ id INTEGER PRIMARY KEY CHECK (id = 1),
24
+ selector TEXT NOT NULL CHECK (trim(selector) <> ''),
25
+ thinking TEXT CHECK (thinking IS NULL OR trim(thinking) <> ''),
26
+ advisor_thinking TEXT CHECK (advisor_thinking IS NULL OR trim(advisor_thinking) <> '')
27
+ ) STRICT;
28
+ CREATE TABLE cache_entry (
29
+ key TEXT PRIMARY KEY,
30
+ fingerprint TEXT NOT NULL,
31
+ fetched_at INTEGER NOT NULL,
32
+ payload TEXT NOT NULL
33
+ ) STRICT, WITHOUT ROWID;`,
34
+ ];
35
+
36
+ export const KIT_DB_SCHEMA_VERSION: number = MIGRATIONS.length;
37
+
38
+ export class KitDbTooNewError extends Error {}
39
+
40
+ export function kitDbFile(home: string): string {
41
+ return p(home, ".docks-kit", "kit.db");
42
+ }
43
+
44
+ function userVersion(db: DatabaseSync): number {
45
+ const version = db.prepare("PRAGMA user_version").get()?.["user_version"];
46
+ return typeof version === "number" ? version : 0;
47
+ }
48
+
49
+ /** Run fn inside BEGIN IMMEDIATE; roll back and rethrow on any error. */
50
+ export function inTransaction<T>(db: DatabaseSync, fn: () => T): T {
51
+ db.exec("BEGIN IMMEDIATE");
52
+ try {
53
+ const result = fn();
54
+ db.exec("COMMIT");
55
+ return result;
56
+ } catch (error) {
57
+ try {
58
+ db.exec("ROLLBACK");
59
+ } catch {
60
+ // SQLite already rolled back (for example on SQLITE_FULL); keep the original error.
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ function tooNew(version: number): KitDbTooNewError {
67
+ return new KitDbTooNewError(
68
+ `~/.docks-kit/kit.db uses schema ${version}; this docks-kit knows schema ${KIT_DB_SCHEMA_VERSION}. Upgrade docks-kit (docks-kit update).`,
69
+ );
70
+ }
71
+
72
+ /** Returns true when this call applied migrations. */
73
+ function migrate(db: DatabaseSync): boolean {
74
+ const current = userVersion(db);
75
+ if (current > KIT_DB_SCHEMA_VERSION) throw tooNew(current);
76
+ if (current === KIT_DB_SCHEMA_VERSION) return false;
77
+ return inTransaction(db, () => {
78
+ // Another process, possibly a newer kit, can migrate between the first
79
+ // read and the lock; never lower its version.
80
+ const locked = userVersion(db);
81
+ if (locked > KIT_DB_SCHEMA_VERSION) throw tooNew(locked);
82
+ if (locked === KIT_DB_SCHEMA_VERSION) return false;
83
+ for (const sql of MIGRATIONS.slice(locked)) db.exec(sql);
84
+ db.exec(`PRAGMA user_version = ${KIT_DB_SCHEMA_VERSION}`);
85
+ return true;
86
+ });
87
+ }
88
+
89
+ interface LegacyState {
90
+ readonly harnesses: ReadonlyArray<string>;
91
+ readonly session?: { selector: string; thinking: string | null; advisorThinking: string | null };
92
+ }
93
+
94
+ export function isNonBlankString(value: unknown): value is string {
95
+ return typeof value === "string" && value.trim() !== "";
96
+ }
97
+
98
+ /** A blank or non-string value is stored as NULL. */
99
+ export function nonBlankOrNull(value: unknown): string | null {
100
+ return isNonBlankString(value) ? value : null;
101
+ }
102
+
103
+ // Same validation the JSON store applied: root object, version 1, non-blank
104
+ // selector and levels. Unknown or duplicate harness names are dropped by the
105
+ // table's CHECK and PRIMARY KEY through INSERT OR IGNORE.
106
+ function parseLegacyState(file: string): LegacyState | undefined {
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
114
+ if (!("version" in parsed) || parsed.version !== 1) return undefined;
115
+
116
+ const rawHarnesses = "harnesses" in parsed ? parsed.harnesses : undefined;
117
+ const harnesses = Array.isArray(rawHarnesses)
118
+ ? rawHarnesses.filter((value): value is string => typeof value === "string")
119
+ : [];
120
+ const entry = "ompSession" in parsed ? parsed.ompSession : undefined;
121
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return { harnesses };
122
+ const selector = nonBlankOrNull("selector" in entry ? entry.selector : undefined);
123
+ if (selector === null) return { harnesses };
124
+ return {
125
+ harnesses,
126
+ session: {
127
+ selector,
128
+ thinking: nonBlankOrNull("thinking" in entry ? entry.thinking : undefined),
129
+ advisorThinking: nonBlankOrNull(
130
+ "advisorThinking" in entry ? entry.advisorThinking : undefined,
131
+ ),
132
+ },
133
+ };
134
+ }
135
+
136
+ function importLegacyState(db: DatabaseSync, legacy: string): void {
137
+ inTransaction(db, () => {
138
+ const rows = db
139
+ .prepare(
140
+ "SELECT (SELECT count(*) FROM harness_selection) + (SELECT count(*) FROM omp_session) AS n",
141
+ )
142
+ .get()?.["n"];
143
+ if (rows !== 0) return;
144
+ const state = parseLegacyState(legacy);
145
+ if (state === undefined) return;
146
+ const insertHarness = db.prepare(
147
+ "INSERT OR IGNORE INTO harness_selection (harness) VALUES (?)",
148
+ );
149
+ for (const harness of state.harnesses) insertHarness.run(harness);
150
+ if (state.session !== undefined) {
151
+ db.prepare(
152
+ "INSERT INTO omp_session (id, selector, thinking, advisor_thinking) VALUES (1, ?, ?, ?)",
153
+ ).run(state.session.selector, state.session.thinking, state.session.advisorThinking);
154
+ }
155
+ });
156
+ // An invalid file is renamed too, so no later run parses it again.
157
+ try {
158
+ renameSync(legacy, `${legacy}.migrated`);
159
+ } catch (error) {
160
+ // A parallel process renamed it first.
161
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : "";
162
+ if (code !== "ENOENT") throw error;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Open kit.db, migrate it, import the legacy state file once, and run fn.
168
+ * A read with neither kit.db nor state.json present creates nothing and
169
+ * returns undefined.
170
+ */
171
+ export function withKitDb<T>(
172
+ home: string,
173
+ access: "read" | "write",
174
+ fn: (db: DatabaseSync) => T,
175
+ ): T | undefined {
176
+ const file = kitDbFile(home);
177
+ const legacy = p(home, ".docks-kit", "state.json");
178
+ if (access === "read" && !existsSync(file) && !existsSync(legacy)) return undefined;
179
+
180
+ const directory = p(home, ".docks-kit");
181
+ // `mode` applies only when mkdir creates the path, so an existing permissive
182
+ // ~/.docks-kit would keep its mode.
183
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
184
+ chmodSync(directory, 0o700);
185
+
186
+ const db = new DatabaseSync(file);
187
+ try {
188
+ // SQLite gives the -wal and -shm files the mode of the main file.
189
+ if (process.platform !== "win32") chmodSync(file, 0o600);
190
+ // busy_timeout first: the switch to WAL needs a lock.
191
+ db.exec(
192
+ "PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON;",
193
+ );
194
+ const migrated = migrate(db);
195
+ const importing = existsSync(legacy);
196
+ if (importing) importLegacyState(db, legacy);
197
+ const result = fn(db);
198
+ // Fold the WAL into kit.db so a copy of kit.db alone holds every row.
199
+ // Reads can migrate or import too, so they checkpoint after writing.
200
+ if (access === "write" || migrated || importing) {
201
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
202
+ }
203
+ return result;
204
+ } finally {
205
+ db.close();
206
+ }
207
+ }
208
+
209
+ // A new kit release refetches every cached row.
210
+ const CACHE_FINGERPRINT = GENERATED_PACKAGE_VERSION;
211
+
212
+ /** Return a fresh cached payload; every failure is a miss. */
213
+ export function readCache(
214
+ home: string,
215
+ key: string,
216
+ maxAgeMs: number,
217
+ now: number,
218
+ ): string | undefined {
219
+ try {
220
+ return withKitDb(home, "read", (db) => {
221
+ const row = db
222
+ .prepare("SELECT fingerprint, fetched_at, payload FROM cache_entry WHERE key = ?")
223
+ .get(key);
224
+ const fetchedAt = row?.["fetched_at"];
225
+ const payload = row?.["payload"];
226
+ if (row?.["fingerprint"] !== CACHE_FINGERPRINT) return undefined;
227
+ if (typeof fetchedAt !== "number" || typeof payload !== "string") return undefined;
228
+ return now - fetchedAt <= maxAgeMs ? payload : undefined;
229
+ });
230
+ } catch {
231
+ return undefined;
232
+ }
233
+ }
234
+
235
+ /** Store a successful lookup; a failed write does nothing. */
236
+ export function writeCache(home: string, key: string, payload: string, now: number): void {
237
+ try {
238
+ withKitDb(home, "write", (db) =>
239
+ inTransaction(db, () => {
240
+ db.prepare("DELETE FROM cache_entry WHERE fingerprint <> ?").run(CACHE_FINGERPRINT);
241
+ db.prepare(
242
+ `INSERT INTO cache_entry (key, fingerprint, fetched_at, payload) VALUES (?, ?, ?, ?)
243
+ ON CONFLICT(key) DO UPDATE SET fingerprint = excluded.fingerprint, fetched_at = excluded.fetched_at, payload = excluded.payload`,
244
+ ).run(key, CACHE_FINGERPRINT, now, payload);
245
+ }),
246
+ );
247
+ } catch {
248
+ // The cache is an optimization; the next run fetches again.
249
+ }
250
+ }
@@ -0,0 +1,263 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { capture, p, which } from "./exec";
4
+ import { LEGACY_SELECTION, readHarnessSelection } from "./harnesses";
5
+ import { isObject, parseJson } from "./jq";
6
+ import { isNonBlankString, readCache, writeCache } from "./kitDb";
7
+ import { modelCatalog } from "./models";
8
+ import type { CatalogSource, CatalogTool, FetchLike, ResolvedCatalog } from "./sharedTypes";
9
+
10
+ export interface LiveCatalogInputs {
11
+ readonly home: string;
12
+ readonly refresh: boolean;
13
+ readonly fetch: FetchLike;
14
+ readonly which: (name: string) => string;
15
+ readonly capture: (cmd: string, args: ReadonlyArray<string>) => Promise<string>;
16
+ readonly now: () => number;
17
+ }
18
+
19
+ export const defaultLiveInputs = (home: string, refresh = false): LiveCatalogInputs => ({
20
+ home,
21
+ refresh,
22
+ fetch: globalThis.fetch,
23
+ which,
24
+ capture,
25
+ now: Date.now,
26
+ });
27
+
28
+ type LiveModel = ResolvedCatalog["models"][number];
29
+ type LiveLookup = {
30
+ readonly source: Exclude<CatalogSource, "curated">;
31
+ readonly fetchedAt?: string;
32
+ readonly models: ReadonlyArray<LiveModel>;
33
+ };
34
+ type Lookup = LiveLookup | { readonly fallbackReason: string };
35
+
36
+ export function curatedCatalog(tool: CatalogTool): ResolvedCatalog {
37
+ if (tool === "omp") return { tool, source: "curated", verified: "?", models: [] };
38
+ return { tool, source: "curated", ...modelCatalog(tool) };
39
+ }
40
+
41
+ function mergeCurated(curated: ResolvedCatalog, live: ReadonlyArray<LiveModel>): Array<LiveModel> {
42
+ const models: Array<LiveModel> = [];
43
+ const notes = new Map<string, string>();
44
+ for (const entry of curated.models) {
45
+ if (entry.kind === "alias") models.push(entry);
46
+ if (entry.note !== undefined) notes.set(entry.id, entry.note);
47
+ }
48
+ for (const entry of live) {
49
+ const note = notes.get(entry.id) ?? entry.note;
50
+ models.push(
51
+ note === undefined ? { id: entry.id, kind: "id" } : { id: entry.id, kind: "id", note },
52
+ );
53
+ }
54
+ return models;
55
+ }
56
+
57
+ export async function resolveCatalog(
58
+ tool: CatalogTool,
59
+ inputs: LiveCatalogInputs,
60
+ ): Promise<ResolvedCatalog> {
61
+ const curated = curatedCatalog(tool);
62
+ if (!(readHarnessSelection(inputs.home) ?? LEGACY_SELECTION).includes(tool)) {
63
+ return { ...curated, fallbackReason: `${tool} harness not enabled (docks-kit harnesses)` };
64
+ }
65
+
66
+ const lookup =
67
+ tool === "claude"
68
+ ? await claudeCatalog(inputs)
69
+ : tool === "codex"
70
+ ? codexCatalog(inputs.home)
71
+ : await ompCatalog(inputs);
72
+ if ("fallbackReason" in lookup) return { ...curated, fallbackReason: lookup.fallbackReason };
73
+ return {
74
+ ...curated,
75
+ source: lookup.source,
76
+ ...(lookup.fetchedAt === undefined ? {} : { fetchedAt: lookup.fetchedAt }),
77
+ models: tool === "omp" ? lookup.models : mergeCurated(curated, lookup.models),
78
+ };
79
+ }
80
+
81
+ const ANTHROPIC_CACHE_KEY = "models:anthropic";
82
+ const ANTHROPIC_CACHE_TTL = 6 * 3600_000;
83
+ const NO_CLAUDE_LOGIN = "no Claude Code login found in ~/.claude/.credentials.json";
84
+
85
+ function cachedClaudeCatalog(payload: string): LiveLookup | undefined {
86
+ const cached = parseJson(payload);
87
+ if (cached === undefined || !isObject(cached)) return undefined;
88
+ const fetchedAt = cached["fetchedAt"];
89
+ const ids = cached["ids"];
90
+ if (typeof fetchedAt !== "string" || !Array.isArray(ids)) {
91
+ return undefined;
92
+ }
93
+ if (!ids.every(isNonBlankString)) return undefined;
94
+ const displayNames = cached["displayNames"];
95
+ // Names align with ids; older cache rows contain ids only.
96
+ const names =
97
+ Array.isArray(displayNames) &&
98
+ displayNames.length === ids.length &&
99
+ displayNames.every((name) => name === null || isNonBlankString(name))
100
+ ? displayNames
101
+ : undefined;
102
+ return {
103
+ source: "anthropic-api",
104
+ fetchedAt,
105
+ models: ids.map((id, index) => {
106
+ const name = names?.[index];
107
+ return typeof name === "string" ? { id, kind: "id", note: name } : { id, kind: "id" };
108
+ }),
109
+ };
110
+ }
111
+
112
+ async function claudeCatalog(inputs: LiveCatalogInputs): Promise<Lookup> {
113
+ if (!inputs.refresh) {
114
+ const cached = readCache(inputs.home, ANTHROPIC_CACHE_KEY, ANTHROPIC_CACHE_TTL, inputs.now());
115
+ if (cached !== undefined) {
116
+ const catalog = cachedClaudeCatalog(cached);
117
+ if (catalog !== undefined) return catalog;
118
+ }
119
+ }
120
+
121
+ let credentialsText: string;
122
+ try {
123
+ credentialsText = readFileSync(p(inputs.home, ".claude", ".credentials.json"), "utf8");
124
+ } catch {
125
+ return { fallbackReason: NO_CLAUDE_LOGIN };
126
+ }
127
+ const credentials = parseJson(credentialsText);
128
+ const oauth =
129
+ credentials !== undefined && isObject(credentials) ? credentials["claudeAiOauth"] : undefined;
130
+ if (oauth === undefined || !isObject(oauth)) return { fallbackReason: NO_CLAUDE_LOGIN };
131
+ const token = oauth["accessToken"];
132
+ const expiresAt = oauth["expiresAt"];
133
+ if (!isNonBlankString(token) || typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
134
+ return { fallbackReason: NO_CLAUDE_LOGIN };
135
+ }
136
+ if (expiresAt <= inputs.now()) {
137
+ return { fallbackReason: "Claude Code login expired; start claude once to refresh it" };
138
+ }
139
+
140
+ const models: Array<LiveModel> = [];
141
+ let afterId: string | undefined;
142
+ try {
143
+ for (;;) {
144
+ const url = `https://api.anthropic.com/v1/models?limit=1000${afterId === undefined ? "" : `&after_id=${encodeURIComponent(afterId)}`}`;
145
+ const response = await inputs.fetch(url, {
146
+ method: "GET",
147
+ headers: {
148
+ Authorization: `Bearer ${token}`,
149
+ "anthropic-version": "2023-06-01",
150
+ "anthropic-beta": "oauth-2025-04-20",
151
+ },
152
+ signal: AbortSignal.timeout(5000),
153
+ });
154
+ if (response.status !== 200) {
155
+ return { fallbackReason: `Anthropic API returned HTTP ${response.status}` };
156
+ }
157
+ const page: unknown = await response.json();
158
+ if (typeof page !== "object" || page === null || Array.isArray(page)) {
159
+ throw new Error("invalid response");
160
+ }
161
+ if (
162
+ !("data" in page) ||
163
+ !Array.isArray(page.data) ||
164
+ !("has_more" in page) ||
165
+ typeof page.has_more !== "boolean"
166
+ ) {
167
+ throw new Error("invalid response");
168
+ }
169
+ for (const row of page.data) {
170
+ if (typeof row !== "object" || row === null || Array.isArray(row) || !("id" in row))
171
+ continue;
172
+ const id = row.id;
173
+ if (!isNonBlankString(id)) continue;
174
+ const name = "display_name" in row ? row.display_name : undefined;
175
+ models.push(isNonBlankString(name) ? { id, kind: "id", note: name } : { id, kind: "id" });
176
+ }
177
+ if (!page.has_more) break;
178
+ const lastId = "last_id" in page ? page.last_id : undefined;
179
+ if (!isNonBlankString(lastId) || lastId === afterId) throw new Error("invalid response");
180
+ afterId = lastId;
181
+ }
182
+ } catch (error) {
183
+ return {
184
+ fallbackReason: `Anthropic API unreachable: ${error instanceof Error ? error.message : String(error)}`,
185
+ };
186
+ }
187
+
188
+ // An empty list would drop every curated id; never cache it.
189
+ if (models.length === 0) return { fallbackReason: "Anthropic API returned no models" };
190
+ const fetched = inputs.now();
191
+ const fetchedAt = new Date(fetched).toISOString();
192
+ writeCache(
193
+ inputs.home,
194
+ ANTHROPIC_CACHE_KEY,
195
+ JSON.stringify({
196
+ fetchedAt,
197
+ ids: models.map((model) => model.id),
198
+ displayNames: models.map((model) => model.note ?? null),
199
+ }),
200
+ fetched,
201
+ );
202
+ return { source: "anthropic-api", fetchedAt, models };
203
+ }
204
+
205
+ const NO_CODEX_CACHE = "no Codex model cache (~/.codex/models_cache.json); run codex once";
206
+ const UNREADABLE_CODEX_CACHE = "Codex model cache is unreadable";
207
+
208
+ function codexCatalog(home: string): Lookup {
209
+ let text: string;
210
+ try {
211
+ text = readFileSync(p(home, ".codex", "models_cache.json"), "utf8");
212
+ } catch (error) {
213
+ const missing =
214
+ typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
215
+ return { fallbackReason: missing ? NO_CODEX_CACHE : UNREADABLE_CODEX_CACHE };
216
+ }
217
+ const cache = parseJson(text);
218
+ if (cache === undefined || !isObject(cache) || !Array.isArray(cache["models"])) {
219
+ return { fallbackReason: UNREADABLE_CODEX_CACHE };
220
+ }
221
+ const models: Array<LiveModel> = [];
222
+ for (const row of cache["models"]) {
223
+ if (!isObject(row) || row["visibility"] !== "list") continue;
224
+ const id = row["slug"];
225
+ if (!isNonBlankString(id)) continue;
226
+ const name = row["display_name"];
227
+ models.push(isNonBlankString(name) ? { id, kind: "id", note: name } : { id, kind: "id" });
228
+ }
229
+ if (models.length === 0) return { fallbackReason: "Codex model cache lists no models" };
230
+ const fetchedAt = cache["fetched_at"];
231
+ return {
232
+ source: "codex-cache",
233
+ ...(typeof fetchedAt === "string" ? { fetchedAt } : {}),
234
+ models,
235
+ };
236
+ }
237
+
238
+ const NO_OMP_CATALOG = "'omp models --json' returned no usable catalog";
239
+
240
+ async function ompCatalog(inputs: LiveCatalogInputs): Promise<Lookup> {
241
+ const omp = inputs.which("omp");
242
+ if (omp === "") return { fallbackReason: "omp not found on PATH" };
243
+ let raw: string;
244
+ try {
245
+ raw = await inputs.capture(omp, ["models", "--json"]);
246
+ } catch {
247
+ return { fallbackReason: NO_OMP_CATALOG };
248
+ }
249
+ const catalog = parseJson(raw);
250
+ if (catalog === undefined || !isObject(catalog) || !Array.isArray(catalog["models"])) {
251
+ return { fallbackReason: NO_OMP_CATALOG };
252
+ }
253
+ const models: Array<LiveModel> = [];
254
+ for (const row of catalog["models"]) {
255
+ if (!isObject(row) || row["kind"] !== "chat") continue;
256
+ const id = row["selector"];
257
+ if (!isNonBlankString(id)) continue;
258
+ const name = row["name"];
259
+ models.push(isNonBlankString(name) ? { id, kind: "id", note: name } : { id, kind: "id" });
260
+ }
261
+ if (models.length === 0) return { fallbackReason: NO_OMP_CATALOG };
262
+ return { source: "omp-cli", fetchedAt: new Date(inputs.now()).toISOString(), models };
263
+ }
@@ -1,23 +1,25 @@
1
1
  /**
2
- * Model-catalog helpers: manifest listing plus Claude/Codex model validation.
3
- * Message strings are covered by the golden suites.
2
+ * Model-catalog helpers: SoT parsing, listing, and Claude/Codex model validation.
3
+ * Curated message strings are covered by the golden suites.
4
4
  */
5
5
  import type { Ctx } from "./index";
6
6
  import { isObject, parseJson } from "./jq";
7
7
  import { payloadDisplayPath, payloadText } from "../payload";
8
- import type { JsonObject } from "./sharedTypes";
8
+ import type { CatalogSource, JsonObject, ResolvedCatalog } from "./sharedTypes";
9
9
 
10
- export interface ModelEntry {
11
- readonly id: string;
12
- readonly kind: "alias" | "id";
13
- readonly note?: string;
14
- }
10
+ export type ModelEntry = ResolvedCatalog["models"][number];
15
11
 
16
12
  export interface ModelCatalog {
17
13
  readonly verified: string;
18
14
  readonly models: ReadonlyArray<ModelEntry>;
19
15
  }
20
16
 
17
+ const LIVE_SOURCE_LABELS: Record<Exclude<CatalogSource, "curated">, string> = {
18
+ "anthropic-api": "Anthropic API via Claude Code login",
19
+ "codex-cache": "~/.codex/models_cache.json",
20
+ "omp-cli": "omp models --json",
21
+ };
22
+
21
23
  function toolEntry(tool: string): JsonObject | undefined {
22
24
  const doc = parseJson(payloadText("SoT/models.json"));
23
25
  if (doc === undefined || !isObject(doc)) return undefined;
@@ -49,43 +51,47 @@ export function modelCatalog(tool: string): ModelCatalog {
49
51
  return { verified: typeof verified === "string" ? verified : "?", models };
50
52
  }
51
53
 
52
- export function printModels(ctx: Ctx, tool: string): void {
54
+ export function printModels(ctx: Ctx, catalog: ResolvedCatalog): void {
53
55
  const { echo, warn } = ctx.services.logger;
54
- const entry = toolEntry(tool);
55
- if (entry === undefined) {
56
+ const { tool, models, source, verified, fetchedAt } = catalog;
57
+ if (source === "curated" && models.length === 0) {
56
58
  warn(`Model catalog unavailable (${payloadDisplayPath("SoT/models.json")})`);
57
59
  return;
58
60
  }
59
- const verified = typeof entry["verified"] === "string" ? entry["verified"] : "?";
60
- const lines = [`Available ${tool} models (kit-verified ${verified} — SoT/models.json):`];
61
- for (const m of modelEntries(entry)) {
62
- const note = typeof m["note"] === "string" ? ` — ${m["note"]}` : "";
63
- lines.push(` ${String(m["id"] ?? "")}${note}`);
61
+ const header =
62
+ source === "curated"
63
+ ? `Available ${tool} models (kit-verified ${verified} — SoT/models.json):`
64
+ : `Available ${tool} models (live — ${LIVE_SOURCE_LABELS[source]}, fetched ${fetchedAt ?? "?"}; aliases and notes from SoT/models.json):`;
65
+ echo(header);
66
+ for (const model of models) {
67
+ echo(` ${model.id}${model.note === undefined ? "" : ` — ${model.note}`}`);
64
68
  }
65
69
  if (tool === "claude")
66
- lines.push(" (full claude-* model IDs outside the catalog are accepted with a warning)");
67
- if (tool === "codex")
68
- lines.push(" (well-formed IDs outside the catalog are accepted with a warning)");
69
- for (const line of lines) echo(line);
70
+ echo(" (full claude-* model IDs outside the catalog are accepted with a warning)");
71
+ if (tool === "codex") echo(" (well-formed IDs outside the catalog are accepted with a warning)");
70
72
  }
71
73
 
72
- export function validateClaudeModel(ctx: Ctx, m: string): boolean {
74
+ export function validateClaudeModel(ctx: Ctx, m: string, catalog: ResolvedCatalog): boolean {
73
75
  if (m === "") return false;
74
- if (modelCatalog("claude").models.some((entry) => entry.id === m)) return true;
76
+ if (catalog.models.some((entry) => entry.id === m)) return true;
75
77
  if (m.startsWith("claude-")) {
76
78
  ctx.services.logger.warn(
77
- `Claude model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway`,
79
+ catalog.source === "curated"
80
+ ? `Claude model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway`
81
+ : `Claude model '${m}' is not in the live Anthropic model list — applying anyway`,
78
82
  );
79
83
  return true;
80
84
  }
81
85
  return false;
82
86
  }
83
87
 
84
- export function validateCodexModel(ctx: Ctx, m: string): boolean {
88
+ export function validateCodexModel(ctx: Ctx, m: string, catalog: ResolvedCatalog): boolean {
85
89
  if (!/^[A-Za-z0-9._-]+$/.test(m)) return false;
86
- if (!modelCatalog("codex").models.some((entry) => entry.id === m)) {
90
+ if (!catalog.models.some((entry) => entry.id === m)) {
87
91
  ctx.services.logger.warn(
88
- `Codex model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway (check ~/.codex/config.toml if Codex rejects it)`,
92
+ catalog.source === "curated"
93
+ ? `Codex model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway (check ~/.codex/config.toml if Codex rejects it)`
94
+ : `Codex model '${m}' is not in the live Codex model list — applying anyway (check ~/.codex/config.toml if Codex rejects it)`,
89
95
  );
90
96
  }
91
97
  return true;
@@ -11,12 +11,13 @@ import { syncCodexModel } from "./codexToml";
11
11
  import type { Ctx } from "./index";
12
12
  import { isObject, parseJson, type Json } from "./jq";
13
13
  import { printModels, validateClaudeModel, validateCodexModel } from "./models";
14
+ import { defaultLiveInputs, resolveCatalog } from "./liveModels";
14
15
  import { bunBootstrap } from "./bun";
15
- import { installedVersion, present, report } from "./toolchain";
16
+ import { installedVersion, outdatedReport, present, report } from "./toolchain";
16
17
 
17
- export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
18
+ export async function modeModel(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
18
19
  const { echo, err, warn } = ctx.services.logger;
19
- let tool = "";
20
+ let tool: "claude" | "codex" | "" = "";
20
21
  let value = "";
21
22
  for (const arg of args) {
22
23
  if (arg === "--dry-run") ctx.dryRun = true;
@@ -32,7 +33,7 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
32
33
  err("Usage: model <claude|codex> [value] [--dry-run]");
33
34
  return 2;
34
35
  }
35
-
36
+ const catalog = await resolveCatalog(tool, defaultLiveInputs(ctx.home));
36
37
  if (value === "") {
37
38
  if (tool === "claude") {
38
39
  const deployed = p(ctx.home, ".claude", "settings.json");
@@ -61,20 +62,20 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
61
62
  echo(`deployed: ${tomlModelText(result.data)}`);
62
63
  echo(`SoT: ${tomlModelText(payloadText("SoT/.codex/config.toml"))}`);
63
64
  }
64
- printModels(ctx, tool);
65
+ printModels(ctx, catalog);
65
66
  return 0;
66
67
  }
67
68
 
68
69
  if (tool === "claude") {
69
- if (!validateClaudeModel(ctx, value)) {
70
- printModels(ctx, "claude");
70
+ if (!validateClaudeModel(ctx, value, catalog)) {
71
+ printModels(ctx, catalog);
71
72
  err(`Invalid Claude model '${value}'`);
72
73
  return 2;
73
74
  }
74
75
  syncClaudeModel(ctx, value);
75
76
  } else {
76
- if (!validateCodexModel(ctx, value)) {
77
- printModels(ctx, "codex");
77
+ if (!validateCodexModel(ctx, value, catalog)) {
78
+ printModels(ctx, catalog);
78
79
  err(`Invalid Codex model '${value}'`);
79
80
  return 2;
80
81
  }
@@ -133,8 +134,12 @@ export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Prom
133
134
  await report(ctx);
134
135
  return 0;
135
136
  }
137
+ if (op === "outdated") {
138
+ await outdatedReport(ctx, { refresh: args.includes("--refresh") });
139
+ return 0;
140
+ }
136
141
  if (op !== "ensure") {
137
- err("Usage: toolchain [check|ensure <tool>]");
142
+ err("Usage: toolchain [check|ensure <tool>|outdated [--refresh]]");
138
143
  return 2;
139
144
  }
140
145
  if (tool === "") {