docks-kit 0.18.1 → 0.19.1

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,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,14 @@ 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";
17
+ import { topLevelTomlString } from "../manifests";
16
18
 
17
- export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
19
+ export async function modeModel(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
18
20
  const { echo, err, warn } = ctx.services.logger;
19
- let tool = "";
21
+ let tool: "claude" | "codex" | "" = "";
20
22
  let value = "";
21
23
  for (const arg of args) {
22
24
  if (arg === "--dry-run") ctx.dryRun = true;
@@ -32,7 +34,7 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
32
34
  err("Usage: model <claude|codex> [value] [--dry-run]");
33
35
  return 2;
34
36
  }
35
-
37
+ const catalog = await resolveCatalog(tool, defaultLiveInputs(ctx.home));
36
38
  if (value === "") {
37
39
  if (tool === "claude") {
38
40
  const deployed = p(ctx.home, ".claude", "settings.json");
@@ -58,23 +60,23 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
58
60
  err(`Failed to read ~/.codex/config.toml: ${String(result.error)}`);
59
61
  return 1;
60
62
  }
61
- echo(`deployed: ${tomlModelText(result.data)}`);
62
- echo(`SoT: ${tomlModelText(payloadText("SoT/.codex/config.toml"))}`);
63
+ echo(`deployed: ${topLevelTomlString(result.data, "model") ?? ""}`);
64
+ echo(`SoT: ${topLevelTomlString(payloadText("SoT/.codex/config.toml"), "model") ?? ""}`);
63
65
  }
64
- printModels(ctx, tool);
66
+ printModels(ctx, catalog);
65
67
  return 0;
66
68
  }
67
69
 
68
70
  if (tool === "claude") {
69
- if (!validateClaudeModel(ctx, value)) {
70
- printModels(ctx, "claude");
71
+ if (!validateClaudeModel(ctx, value, catalog)) {
72
+ printModels(ctx, catalog);
71
73
  err(`Invalid Claude model '${value}'`);
72
74
  return 2;
73
75
  }
74
76
  syncClaudeModel(ctx, value);
75
77
  } else {
76
- if (!validateCodexModel(ctx, value)) {
77
- printModels(ctx, "codex");
78
+ if (!validateCodexModel(ctx, value, catalog)) {
79
+ printModels(ctx, catalog);
78
80
  err(`Invalid Codex model '${value}'`);
79
81
  return 2;
80
82
  }
@@ -111,15 +113,6 @@ function jsonModelText(text: string): string {
111
113
  return typeof v === "string" ? v : JSON.stringify(v);
112
114
  }
113
115
 
114
- /** `awk -F'"' '/^model[[:space:]]*=/{print $2; exit}'`. */
115
-
116
- function tomlModelText(text: string): string {
117
- for (const line of text.split("\n")) {
118
- if (/^model[ \t]*=/.test(line)) return line.split('"')[1] ?? "";
119
- }
120
- return "";
121
- }
122
-
123
116
  export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
124
117
  const { echo, err, verbose } = ctx.services.logger;
125
118
  const words = args.filter((arg) => !arg.startsWith("--"));
@@ -128,13 +121,21 @@ export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Prom
128
121
  for (const arg of args) {
129
122
  if (arg === "--verbose") ctx.verbose = true;
130
123
  }
124
+ if (words.length > (op === "ensure" ? 2 : 1)) {
125
+ err("Usage: toolchain [check|ensure <tool>|outdated [--refresh]]");
126
+ return 2;
127
+ }
131
128
 
132
129
  if (op === "check") {
133
130
  await report(ctx);
134
131
  return 0;
135
132
  }
133
+ if (op === "outdated") {
134
+ await outdatedReport(ctx, { refresh: args.includes("--refresh") });
135
+ return 0;
136
+ }
136
137
  if (op !== "ensure") {
137
- err("Usage: toolchain [check|ensure <tool>]");
138
+ err("Usage: toolchain [check|ensure <tool>|outdated [--refresh]]");
138
139
  return 2;
139
140
  }
140
141
  if (tool === "") {
@@ -8,6 +8,7 @@ import type { Ctx } from "./index";
8
8
  import { LEGACY_SELECTION, readHarnessSelection, type Harness } from "./harnesses";
9
9
  import { advisorCatalog, advisorFlagGrammar, effortCatalog, effortFlagGrammar } from "../efforts";
10
10
  import { printModels } from "./models";
11
+ import { curatedCatalog } from "./liveModels";
11
12
  import { ExitError, printCatalog, printUsage } from "./parseHelp";
12
13
  import {
13
14
  KNOWN_CLAUDE_OPTIN_PLUGINS,
@@ -93,11 +94,11 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
93
94
  ctx.verbose = true;
94
95
  continue;
95
96
  case "--claude-model":
96
- printModels(ctx, "claude");
97
+ printModels(ctx, curatedCatalog("claude"));
97
98
  err("--claude-model requires a value: --claude-model=<model>");
98
99
  throw new ExitError(2);
99
100
  case "--codex-model":
100
- printModels(ctx, "codex");
101
+ printModels(ctx, curatedCatalog("codex"));
101
102
  err("--codex-model requires a value: --codex-model=<model>");
102
103
  throw new ExitError(2);
103
104
  case "--claude-effort":
@@ -17,6 +17,7 @@ import {
17
17
  isEffortModifierValue,
18
18
  } from "../efforts";
19
19
  import { printModels, validateClaudeModel, validateCodexModel } from "./models";
20
+ import { defaultLiveInputs, resolveCatalog } from "./liveModels";
20
21
  import { ExitError, printCatalog } from "./parseHelp";
21
22
 
22
23
  export const KNOWN_CLAUDE_OPTIN_PLUGINS = ["supabase", "n8n"];
@@ -169,7 +170,7 @@ export function isScalarModifierFlag(value: string): value is ScalarModifierFlag
169
170
  return SCALAR_MODIFIER_FLAGS[value as ScalarModifierFlag] === true;
170
171
  }
171
172
 
172
- export function validateModifierFlags(ctx: Ctx): void {
173
+ export async function validateModifierFlags(ctx: Ctx): Promise<void> {
173
174
  const { err, warn } = ctx.services.logger;
174
175
  const supplied = (flag: ModifierFlag): boolean =>
175
176
  MODIFIER_METADATA[flag].hasValue(ctx) || ctx.modifierFlags?.has(flag) === true;
@@ -185,8 +186,9 @@ export function validateModifierFlags(ctx: Ctx): void {
185
186
  }
186
187
 
187
188
  if (supplied("--claude-model")) {
188
- if (!validateClaudeModel(ctx, ctx.claudeModel)) {
189
- printModels(ctx, "claude");
189
+ const catalog = await resolveCatalog("claude", defaultLiveInputs(ctx.home));
190
+ if (!validateClaudeModel(ctx, ctx.claudeModel, catalog)) {
191
+ printModels(ctx, catalog);
190
192
  err(`Invalid Claude model '${ctx.claudeModel}' — use an alias above or a full claude-* ID`);
191
193
  throw new ExitError(2);
192
194
  }
@@ -206,8 +208,9 @@ export function validateModifierFlags(ctx: Ctx): void {
206
208
  }
207
209
  }
208
210
  if (supplied("--codex-model")) {
209
- if (!validateCodexModel(ctx, ctx.codexModel)) {
210
- printModels(ctx, "codex");
211
+ const catalog = await resolveCatalog("codex", defaultLiveInputs(ctx.home));
212
+ if (!validateCodexModel(ctx, ctx.codexModel, catalog)) {
213
+ printModels(ctx, catalog);
211
214
  err(`Invalid Codex model '${ctx.codexModel}' — must match ^[A-Za-z0-9._-]+$`);
212
215
  throw new ExitError(2);
213
216
  }
@@ -9,6 +9,22 @@ import type { ModifierFlag } from "./engineCtx";
9
9
  /** Manifest-record object: the `{ [key]: Json }` shape redeclared across manifest readers. */
10
10
  export type JsonObject = { [key: string]: Json };
11
11
 
12
+ export type CatalogTool = "claude" | "codex" | "omp";
13
+ export type CatalogSource = "anthropic-api" | "codex-cache" | "omp-cli" | "curated";
14
+
15
+ export interface ResolvedCatalog {
16
+ readonly tool: CatalogTool;
17
+ readonly source: CatalogSource;
18
+ readonly verified: string;
19
+ readonly fetchedAt?: string;
20
+ readonly fallbackReason?: string;
21
+ readonly models: ReadonlyArray<{
22
+ readonly id: string;
23
+ readonly kind: "alias" | "id";
24
+ readonly note?: string;
25
+ }>;
26
+ }
27
+
12
28
  /**
13
29
  * Shared core of per-tool settings-edit descriptors (Claude JSON modifiers,
14
30
  * Codex TOML passes). Each tool narrows `key` to its own vocabulary and keeps
@@ -44,3 +60,6 @@ export type ScalarModifierFlag = Exclude<
44
60
  ModifierFlag,
45
61
  "--claude-compact-window" | "--claude-permissive" | "--claude-plugin"
46
62
  >;
63
+
64
+ /** Injected HTTP client: the subset of fetch the network lookups call. */
65
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
@@ -21,7 +21,7 @@ import {
21
21
  export async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
22
22
  const { acquireTerminal, echo, err } = ctx.services.logger;
23
23
  parseArgs(ctx, args);
24
- validateModifierFlags(ctx);
24
+ await validateModifierFlags(ctx);
25
25
  const configuredConcurrency = process.env["DOCKS_KIT_SYNC_CONCURRENCY"];
26
26
  if (configuredConcurrency === undefined || configuredConcurrency === "") {
27
27
  ctx.syncConcurrency = 3;