auto-model-router 0.1.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.
Files changed (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Interactive configuration wizard for `auto-model-router config`.
3
+ *
4
+ * Edits the router's OWN config (`~/.auto-model-router/config.yml`), covering every
5
+ * section: server, openrouter, tiers, tasks, filters, classifier, escalation,
6
+ * hysteresis, cache, budget, ledger, logging.
7
+ *
8
+ * Only fields the user actually changes are written, as a deep-merge partial,
9
+ * so untouched defaults and hand-edited values survive.
10
+ *
11
+ * Input conventions at a field prompt:
12
+ * - Enter (blank) keep the current value (nothing written)
13
+ * - `-` clear the field (writes null, reverting to no value)
14
+ * - anything else parsed per the field kind, validated, re-prompted if bad
15
+ *
16
+ * The terminal plumbing is behind `WizardIo` so the whole flow is testable by
17
+ * feeding a scripted list of answers. We deliberately do NOT use
18
+ * `node:readline/promises`: under Bun, `question()` only resolves the first
19
+ * call when stdin is a pipe, which hangs any scripted or piped run.
20
+ */
21
+
22
+ import type { RouterConfig } from "../config/types.ts";
23
+
24
+ /** A pull-based source of input lines. `null` means end of input. */
25
+ export interface LineSource {
26
+ next(): Promise<string | null>;
27
+ }
28
+
29
+ /** Terminal plumbing for the wizard: line input plus a write sink. */
30
+ export interface WizardIo {
31
+ read: LineSource;
32
+ write(text: string): void;
33
+ }
34
+
35
+ /** A single configurable field. `path` is dotted, e.g. `budget.perDayUsd`. */
36
+ export interface FieldSpec {
37
+ path: string;
38
+ label: string;
39
+ kind: "string" | "number" | "boolean" | "enum" | "stringArray";
40
+ /** For `enum`: the allowed values. */
41
+ options?: readonly string[];
42
+ /** For `number`: inclusive bounds. */
43
+ min?: number;
44
+ max?: number;
45
+ /** Whether the field may be cleared to "no value". */
46
+ optional?: boolean;
47
+ /** Short hint shown with the label. */
48
+ hint?: string;
49
+ }
50
+
51
+ /** A wizard section: a titled group of fields. */
52
+ export interface SectionSpec {
53
+ title: string;
54
+ fields: readonly FieldSpec[];
55
+ }
56
+
57
+ const AXES = ["coding", "agentic", "intelligence"] as const;
58
+
59
+ /** Every field the wizard can edit, grouped into the menu's sections. */
60
+ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
61
+ {
62
+ title: "Server",
63
+ fields: [
64
+ { path: "server.host", label: "Listen host", kind: "string" },
65
+ { path: "server.port", label: "Listen port", kind: "number", min: 1, max: 65535 },
66
+ { path: "server.apiKey", label: "Client bearer token", kind: "string", optional: true },
67
+ { path: "server.harnessId", label: "Default harness id", kind: "string", optional: true },
68
+ ],
69
+ },
70
+ {
71
+ title: "OpenRouter",
72
+ fields: [
73
+ { path: "openrouter.baseUrl", label: "Base URL", kind: "string" },
74
+ { path: "openrouter.title", label: "Attribution title", kind: "string" },
75
+ { path: "openrouter.timeoutMs", label: "Request timeout", kind: "number", min: 1, hint: "ms" },
76
+ { path: "openrouter.catalogTtlMs", label: "Catalog TTL", kind: "number", min: 1, hint: "ms" },
77
+ { path: "openrouter.catalogRefreshMs", label: "Catalog refresh", kind: "number", min: 0, hint: "ms, 0=off" },
78
+ ],
79
+ },
80
+ {
81
+ title: "Tiers",
82
+ fields: [
83
+ {
84
+ path: "adaptiveTierFloors",
85
+ label: "Adaptive floors from available models",
86
+ kind: "boolean",
87
+ hint: "keeps every tier populated",
88
+ },
89
+ { path: "tiers.trivial.minQuality", label: "trivial: min quality", kind: "number", min: 0, max: 100 },
90
+ { path: "tiers.trivial.maxInputPerMtok", label: "trivial: max input $/Mtok", kind: "number", min: 0, optional: true },
91
+ { path: "tiers.simple.minQuality", label: "simple: min quality", kind: "number", min: 0, max: 100 },
92
+ { path: "tiers.simple.maxInputPerMtok", label: "simple: max input $/Mtok", kind: "number", min: 0, optional: true },
93
+ { path: "tiers.moderate.minQuality", label: "moderate: min quality", kind: "number", min: 0, max: 100 },
94
+ { path: "tiers.moderate.maxInputPerMtok", label: "moderate: max input $/Mtok", kind: "number", min: 0, optional: true },
95
+ { path: "tiers.hard.minQuality", label: "hard: min quality", kind: "number", min: 0, max: 100 },
96
+ { path: "tiers.hard.maxInputPerMtok", label: "hard: max input $/Mtok", kind: "number", min: 0, optional: true },
97
+ ],
98
+ },
99
+ {
100
+ title: "Tasks",
101
+ fields: [
102
+ { path: "tasks.coding.axis", label: "coding: axis", kind: "enum", options: AXES },
103
+ { path: "tasks.coding.minQuality", label: "coding: quality floor", kind: "number", min: 0, max: 100, optional: true },
104
+ { path: "tasks.vision.axis", label: "vision: axis", kind: "enum", options: AXES },
105
+ { path: "tasks.vision.minQuality", label: "vision: quality floor", kind: "number", min: 0, max: 100, optional: true },
106
+ { path: "tasks.documentation.axis", label: "documentation: axis", kind: "enum", options: AXES },
107
+ { path: "tasks.documentation.minQuality", label: "documentation: quality floor", kind: "number", min: 0, max: 100, optional: true },
108
+ { path: "tasks.data.axis", label: "data: axis", kind: "enum", options: AXES },
109
+ { path: "tasks.data.minQuality", label: "data: quality floor", kind: "number", min: 0, max: 100, optional: true },
110
+ { path: "tasks.chat.axis", label: "chat: axis", kind: "enum", options: AXES },
111
+ { path: "tasks.chat.minQuality", label: "chat: quality floor", kind: "number", min: 0, max: 100, optional: true },
112
+ ],
113
+ },
114
+ {
115
+ title: "Filters",
116
+ fields: [
117
+ { path: "filters.allow", label: "Allow globs", kind: "stringArray", hint: "comma-separated" },
118
+ { path: "filters.deny", label: "Deny globs", kind: "stringArray", hint: "comma-separated" },
119
+ { path: "filters.includeFree", label: "Include free models", kind: "boolean" },
120
+ { path: "filters.requireToolSupport", label: "Require tool support", kind: "boolean" },
121
+ { path: "filters.minTrust", label: "Min trust", kind: "number", min: 0, max: 1 },
122
+ { path: "filters.minTrustSamples", label: "Min trust samples", kind: "number", min: 0 },
123
+ { path: "filters.trustScopedByHarness", label: "Scope trust per harness", kind: "boolean" },
124
+ { path: "filters.contextHeadroom", label: "Context headroom", kind: "number", min: 1 },
125
+ ],
126
+ },
127
+ {
128
+ title: "Classifier",
129
+ fields: [
130
+ { path: "classifier.ambiguityThreshold", label: "Ambiguity threshold", kind: "number", min: 0, max: 1 },
131
+ { path: "classifier.model", label: "Adjudicator model", kind: "string", optional: true },
132
+ { path: "classifier.maxCostFraction", label: "Max cost fraction", kind: "number", min: 0, max: 1 },
133
+ { path: "classifier.timeoutMs", label: "Adjudicator timeout", kind: "number", min: 1, hint: "ms" },
134
+ { path: "classifier.toolAxis", label: "Tool-call axis", kind: "enum", options: AXES },
135
+ { path: "classifier.chatAxis", label: "Chat axis", kind: "enum", options: AXES },
136
+ ],
137
+ },
138
+ {
139
+ title: "Escalation",
140
+ fields: [
141
+ { path: "escalation.enabled", label: "Enable mid-stream escalation", kind: "boolean" },
142
+ { path: "escalation.probeTokens", label: "Probe tokens", kind: "number", min: 1 },
143
+ { path: "escalation.maxHoldMs", label: "Max hold", kind: "number", min: 1, hint: "ms" },
144
+ { path: "escalation.maxAttempts", label: "Max attempts", kind: "number", min: 1 },
145
+ ],
146
+ },
147
+ {
148
+ title: "Hysteresis",
149
+ fields: [
150
+ { path: "hysteresis.holdTurns", label: "Hold turns", kind: "number", min: 0 },
151
+ { path: "hysteresis.switchMargin", label: "Switch margin", kind: "number", min: 0 },
152
+ { path: "hysteresis.cacheWarmTtlMs", label: "Cache-warm TTL", kind: "number", min: 0, hint: "ms" },
153
+ ],
154
+ },
155
+ {
156
+ title: "Cache",
157
+ fields: [
158
+ { path: "cache.injectBreakpoints", label: "Inject cache breakpoints", kind: "boolean" },
159
+ { path: "cache.maxBreakpoints", label: "Max breakpoints", kind: "number", min: 1 },
160
+ { path: "cache.minPromptTokens", label: "Min prompt tokens", kind: "number", min: 0 },
161
+ ],
162
+ },
163
+ {
164
+ title: "Budget",
165
+ fields: [
166
+ { path: "budget.perTurnUsd", label: "Per-turn cap $", kind: "number", min: 0, optional: true },
167
+ { path: "budget.perConversationUsd", label: "Per-conversation cap $", kind: "number", min: 0, optional: true },
168
+ { path: "budget.perDayUsd", label: "Per-day cap $", kind: "number", min: 0, optional: true },
169
+ { path: "budget.onExceeded", label: "On exceeded", kind: "enum", options: ["downgrade", "reject"] },
170
+ ],
171
+ },
172
+ {
173
+ title: "Ledger",
174
+ fields: [
175
+ { path: "ledger.blendWindowDays", label: "Blend window", kind: "number", min: 1, hint: "days" },
176
+ { path: "ledger.blendMinSamples", label: "Blend min samples", kind: "number", min: 0 },
177
+ { path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
178
+ ],
179
+ },
180
+ {
181
+ title: "Logging",
182
+ fields: [
183
+ { path: "logLevel", label: "Log level", kind: "enum", options: ["silent", "error", "warn", "info", "debug"] },
184
+ ],
185
+ },
186
+ ];
187
+
188
+ /** Reads a dotted path out of a nested object. */
189
+ export function getPath(obj: unknown, path: string): unknown {
190
+ let cur: unknown = obj;
191
+ for (const part of path.split(".")) {
192
+ if (typeof cur !== "object" || cur === null) return undefined;
193
+ cur = (cur as Record<string, unknown>)[part];
194
+ }
195
+ return cur;
196
+ }
197
+
198
+ /** Sets a dotted path, creating intermediate objects as needed. */
199
+ export function setPath(target: Record<string, unknown>, path: string, value: unknown): void {
200
+ const parts = path.split(".");
201
+ const last = parts.length - 1;
202
+ let cur = target;
203
+ for (let i = 0; i < last; i++) {
204
+ const part = parts[i] ?? "";
205
+ const next = cur[part];
206
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
207
+ const fresh: Record<string, unknown> = {};
208
+ cur[part] = fresh;
209
+ cur = fresh;
210
+ } else {
211
+ cur = next as Record<string, unknown>;
212
+ }
213
+ }
214
+ cur[parts[last] ?? ""] = value;
215
+ }
216
+
217
+ /** Result of validating one raw answer against a field spec. */
218
+ export type FieldResult =
219
+ | { ok: true; value: unknown }
220
+ | { ok: false; error: string };
221
+
222
+ /** The sentinel a user types to clear an optional field. */
223
+ export const CLEAR_TOKEN = "-";
224
+
225
+ /** Parses and validates one raw answer for a field. */
226
+ export function validateField(field: FieldSpec, raw: string): FieldResult {
227
+ const text = raw.trim();
228
+
229
+ if (text === CLEAR_TOKEN) {
230
+ if (field.optional !== true) return { ok: false, error: `${field.label} cannot be cleared` };
231
+ return { ok: true, value: null };
232
+ }
233
+
234
+ switch (field.kind) {
235
+ case "string":
236
+ return { ok: true, value: text };
237
+
238
+ case "number": {
239
+ const n = Number(text);
240
+ if (!Number.isFinite(n)) return { ok: false, error: `not a number: ${text}` };
241
+ if (field.min !== undefined && n < field.min) return { ok: false, error: `must be >= ${field.min}` };
242
+ if (field.max !== undefined && n > field.max) return { ok: false, error: `must be <= ${field.max}` };
243
+ return { ok: true, value: n };
244
+ }
245
+
246
+ case "boolean": {
247
+ const lower = text.toLowerCase();
248
+ if (["y", "yes", "true", "1", "on"].includes(lower)) return { ok: true, value: true };
249
+ if (["n", "no", "false", "0", "off"].includes(lower)) return { ok: true, value: false };
250
+ return { ok: false, error: `answer y or n, got: ${text}` };
251
+ }
252
+
253
+ case "enum": {
254
+ const options = field.options ?? [];
255
+ if (!options.includes(text)) return { ok: false, error: `one of: ${options.join(", ")}` };
256
+ return { ok: true, value: text };
257
+ }
258
+
259
+ case "stringArray": {
260
+ const items = text.split(",").map((s) => s.trim()).filter((s) => s !== "");
261
+ return { ok: true, value: items };
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Turns a flat `dotted.path -> value` map of edits into the nested partial
268
+ * object to merge into the config file.
269
+ */
270
+ export function applyAnswers(answers: Record<string, unknown>): Record<string, unknown> {
271
+ const partial: Record<string, unknown> = {};
272
+ for (const [path, value] of Object.entries(answers)) {
273
+ setPath(partial, path, value);
274
+ }
275
+ return partial;
276
+ }
277
+
278
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
279
+ return typeof value === "object" && value !== null && !Array.isArray(value);
280
+ }
281
+
282
+ /**
283
+ * Deep-merges a wizard partial into the on-disk config object.
284
+ *
285
+ * A `null` leaf means "clear this setting": the key is DELETED rather than
286
+ * written as null, so the loader falls back to its default and the schema
287
+ * (which types optional fields as absent, not nullable) still accepts the
288
+ * file. Empty objects left behind by a clear are pruned.
289
+ */
290
+ export function mergeConfigPartial(
291
+ base: Record<string, unknown>,
292
+ partial: Record<string, unknown>,
293
+ ): Record<string, unknown> {
294
+ const out: Record<string, unknown> = { ...base };
295
+
296
+ for (const [key, value] of Object.entries(partial)) {
297
+ if (value === null) {
298
+ delete out[key];
299
+ continue;
300
+ }
301
+
302
+ if (isPlainRecord(value)) {
303
+ const existing = out[key];
304
+ const merged = mergeConfigPartial(isPlainRecord(existing) ? existing : {}, value);
305
+ if (Object.keys(merged).length === 0) delete out[key];
306
+ else out[key] = merged;
307
+ continue;
308
+ }
309
+
310
+ out[key] = Array.isArray(value) ? value.slice() : value;
311
+ }
312
+
313
+ return out;
314
+ }
315
+
316
+ /** Renders a config value the way the prompt shows the current setting. */
317
+ export function formatValue(value: unknown): string {
318
+ if (value === undefined || value === null) return "unset";
319
+ if (Array.isArray(value)) return value.length === 0 ? "empty" : value.join(", ");
320
+ if (typeof value === "boolean") return value ? "y" : "n";
321
+ return String(value);
322
+ }
323
+
324
+ /** Builds the field prompt line, e.g. ` Listen port [8788]: `. */
325
+ function fieldPrompt(field: FieldSpec, current: unknown): string {
326
+ const hint = field.hint !== undefined ? ` (${field.hint})` : "";
327
+ return ` ${field.label}${hint} [${formatValue(current)}]: `;
328
+ }
329
+
330
+ /** Renders the top-level section menu. */
331
+ function renderMenu(edits: Record<string, unknown>): string {
332
+ const lines: string[] = ["", "auto-model-router config", ""];
333
+ WIZARD_SECTIONS.forEach((section, i) => {
334
+ const touched = Object.keys(edits).filter((p) =>
335
+ section.fields.some((f) => f.path === p),
336
+ ).length;
337
+ const mark = touched > 0 ? ` (${touched} changed)` : "";
338
+ lines.push(` ${String(i + 1).padStart(2)}) ${section.title}${mark}`);
339
+ });
340
+ lines.push("");
341
+ const profilesMark = "profiles" in edits ? " (changed)" : "";
342
+ lines.push(` p) Profiles${profilesMark}`);
343
+ lines.push(" a) walk every section");
344
+ lines.push(" s) save and exit");
345
+ lines.push(" q) quit without saving");
346
+ lines.push("");
347
+ const pending = Object.keys(edits).length;
348
+ lines.push(`select${pending > 0 ? ` (${pending} pending)` : ""}: `);
349
+ return lines.join("\n");
350
+ }
351
+
352
+ /**
353
+ * Walks one section, prompting for each field. Invalid answers re-prompt.
354
+ * Returns false if input ended (treated as an abort by the caller).
355
+ */
356
+ async function editSection(
357
+ section: SectionSpec,
358
+ cfg: RouterConfig,
359
+ edits: Record<string, unknown>,
360
+ io: WizardIo,
361
+ ): Promise<boolean> {
362
+ io.write(`\n== ${section.title} ==\n`);
363
+ io.write(` Enter keeps current, "${CLEAR_TOKEN}" clears an optional field\n`);
364
+
365
+ for (const field of section.fields) {
366
+ // Show the pending edit if this field was already touched this session.
367
+ const current = field.path in edits ? edits[field.path] : getPath(cfg, field.path);
368
+
369
+ for (;;) {
370
+ io.write(fieldPrompt(field, current));
371
+ const raw = await io.read.next();
372
+ if (raw === null) return false;
373
+ if (raw.trim() === "") break; // keep current, next field
374
+
375
+ const result = validateField(field, raw);
376
+ if (!result.ok) {
377
+ io.write(` ! ${result.error}\n`);
378
+ continue;
379
+ }
380
+ edits[field.path] = result.value;
381
+ break;
382
+ }
383
+ }
384
+ return true;
385
+ }
386
+
387
+ const TIERS = ["trivial", "simple", "moderate", "hard"] as const;
388
+
389
+ /**
390
+ * Fields of one virtual profile. Paths are relative to the profile record,
391
+ * because profiles live in an ARRAY and are edited as whole elements.
392
+ */
393
+ export const PROFILE_FIELDS: readonly FieldSpec[] = [
394
+ { path: "id", label: "Model id (as clients see it)", kind: "string" },
395
+ { path: "name", label: "Display name", kind: "string" },
396
+ { path: "minTier", label: "Floor tier", kind: "enum", options: TIERS },
397
+ { path: "maxTier", label: "Ceiling tier", kind: "enum", options: TIERS },
398
+ { path: "contextWindow", label: "Context window", kind: "number", min: 1, hint: "tokens" },
399
+ { path: "maxTokens", label: "Max output tokens", kind: "number", min: 1 },
400
+ ];
401
+
402
+ /** A brand-new profile, pre-filled so every field has a sane starting value. */
403
+ function blankProfile(): Record<string, unknown> {
404
+ return {
405
+ id: "",
406
+ name: "",
407
+ minTier: "trivial",
408
+ maxTier: "hard",
409
+ contextWindow: 400000,
410
+ maxTokens: 32000,
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Prompts for each field of a single profile, mutating `profile` in place.
416
+ *
417
+ * When `requireAll` is set (a newly added profile) a blank answer is refused
418
+ * for fields that are still empty, so we never persist a nameless profile.
419
+ */
420
+ async function editProfileFields(
421
+ profile: Record<string, unknown>,
422
+ io: WizardIo,
423
+ requireAll: boolean,
424
+ ): Promise<boolean> {
425
+ for (const field of PROFILE_FIELDS) {
426
+ for (;;) {
427
+ io.write(fieldPrompt(field, profile[field.path]));
428
+ const raw = await io.read.next();
429
+ if (raw === null) return false;
430
+
431
+ if (raw.trim() === "") {
432
+ if (requireAll && profile[field.path] === "") {
433
+ io.write(` ! ${field.label} is required\n`);
434
+ continue;
435
+ }
436
+ break; // keep current
437
+ }
438
+
439
+ const result = validateField(field, raw);
440
+ if (!result.ok) {
441
+ io.write(` ! ${result.error}\n`);
442
+ continue;
443
+ }
444
+ profile[field.path] = result.value;
445
+ break;
446
+ }
447
+ }
448
+ return true;
449
+ }
450
+
451
+ /** Renders the profile list menu. */
452
+ function renderProfileMenu(list: readonly Record<string, unknown>[]): string {
453
+ const lines: string[] = ["", "== Profiles ==", ""];
454
+ if (list.length === 0) lines.push(" (none)");
455
+ list.forEach((profile, i) => {
456
+ const id = String(profile["id"] ?? "");
457
+ const name = String(profile["name"] ?? "");
458
+ const span = `${String(profile["minTier"] ?? "?")}..${String(profile["maxTier"] ?? "?")}`;
459
+ lines.push(` ${String(i + 1).padStart(2)}) ${id} "${name}" [${span}]`);
460
+ });
461
+ lines.push("");
462
+ lines.push(" n) add a profile");
463
+ lines.push(" x<N>) delete profile N");
464
+ lines.push(" b) back");
465
+ lines.push("");
466
+ lines.push("select: ");
467
+ return lines.join("\n");
468
+ }
469
+
470
+ /**
471
+ * Edits the `profiles` array. Because arrays are replaced wholesale on merge,
472
+ * any change records the ENTIRE new array as one edit.
473
+ */
474
+ async function editProfiles(
475
+ cfg: RouterConfig,
476
+ edits: Record<string, unknown>,
477
+ io: WizardIo,
478
+ ): Promise<boolean> {
479
+ const pending = edits["profiles"];
480
+ const list: Record<string, unknown>[] = Array.isArray(pending)
481
+ ? pending.map((p) => ({ ...(p as Record<string, unknown>) }))
482
+ : cfg.profiles.map((p) => ({ ...p }));
483
+
484
+ for (;;) {
485
+ io.write(renderProfileMenu(list));
486
+ const choice = await io.read.next();
487
+ if (choice === null) return false;
488
+ const answer = choice.trim().toLowerCase();
489
+
490
+ if (answer === "b") return true;
491
+
492
+ if (answer === "n") {
493
+ const fresh = blankProfile();
494
+ const ok = await editProfileFields(fresh, io, true);
495
+ if (!ok) return false;
496
+ list.push(fresh);
497
+ edits["profiles"] = list;
498
+ continue;
499
+ }
500
+
501
+ const del = /^x\s*(\d+)$/.exec(answer);
502
+ if (del !== null) {
503
+ const index = Number(del[1]);
504
+ if (index < 1 || index > list.length) {
505
+ io.write(` ! no profile ${index}\n`);
506
+ continue;
507
+ }
508
+ if (list.length === 1) {
509
+ io.write(" ! cannot delete the last profile\n");
510
+ continue;
511
+ }
512
+ list.splice(index - 1, 1);
513
+ edits["profiles"] = list;
514
+ continue;
515
+ }
516
+
517
+ const index = Number(answer);
518
+ const profile = Number.isInteger(index) ? list[index - 1] : undefined;
519
+ if (profile === undefined) {
520
+ io.write(` ! not a choice: ${choice.trim()}\n`);
521
+ continue;
522
+ }
523
+ const ok = await editProfileFields(profile, io, false);
524
+ if (!ok) return false;
525
+ edits["profiles"] = list;
526
+ }
527
+ }
528
+
529
+ /** Outcome of a wizard run. */
530
+ export interface WizardResult {
531
+ /** The partial config to merge, or null when the user quit without saving. */
532
+ partial: Record<string, unknown> | null;
533
+ /** Count of fields the user changed. */
534
+ changed: number;
535
+ }
536
+
537
+ /**
538
+ * Runs the menu-driven wizard. Returns the partial config to write, or a null
539
+ * partial when the user quit (or input ended) without saving.
540
+ */
541
+ export async function runWizard(cfg: RouterConfig, io: WizardIo): Promise<WizardResult> {
542
+ const edits: Record<string, unknown> = {};
543
+
544
+ for (;;) {
545
+ io.write(renderMenu(edits));
546
+ const choice = await io.read.next();
547
+ if (choice === null) return { partial: null, changed: 0 };
548
+
549
+ const answer = choice.trim().toLowerCase();
550
+
551
+ if (answer === "q") return { partial: null, changed: 0 };
552
+
553
+ if (answer === "s") {
554
+ const changed = Object.keys(edits).length;
555
+ if (changed === 0) return { partial: null, changed: 0 };
556
+ return { partial: applyAnswers(edits), changed };
557
+ }
558
+
559
+ if (answer === "p") {
560
+ const ok = await editProfiles(cfg, edits, io);
561
+ if (!ok) return { partial: null, changed: 0 };
562
+ continue;
563
+ }
564
+
565
+ if (answer === "a") {
566
+ for (const section of WIZARD_SECTIONS) {
567
+ const ok = await editSection(section, cfg, edits, io);
568
+ if (!ok) return { partial: null, changed: 0 };
569
+ }
570
+ const ok = await editProfiles(cfg, edits, io);
571
+ if (!ok) return { partial: null, changed: 0 };
572
+ continue;
573
+ }
574
+
575
+ const index = Number(answer);
576
+ const section = Number.isInteger(index) ? WIZARD_SECTIONS[index - 1] : undefined;
577
+ if (section === undefined) {
578
+ io.write(` ! not a choice: ${choice.trim()}\n`);
579
+ continue;
580
+ }
581
+ const ok = await editSection(section, cfg, edits, io);
582
+ if (!ok) return { partial: null, changed: 0 };
583
+ }
584
+ }
585
+
586
+ /**
587
+ * A `LineSource` over a byte stream (stdin). Buffers chunks and splits on
588
+ * newlines, so it behaves identically for a TTY and for piped input.
589
+ */
590
+ export class StreamLineSource implements LineSource {
591
+ private buffer = "";
592
+ private ended = false;
593
+ private readonly decoder = new TextDecoder();
594
+ private readonly iterator: AsyncIterator<Uint8Array>;
595
+
596
+ constructor(stream: AsyncIterable<Uint8Array>) {
597
+ this.iterator = stream[Symbol.asyncIterator]();
598
+ }
599
+
600
+ async next(): Promise<string | null> {
601
+ for (;;) {
602
+ const newline = this.buffer.indexOf("\n");
603
+ if (newline >= 0) {
604
+ const line = this.buffer.slice(0, newline);
605
+ this.buffer = this.buffer.slice(newline + 1);
606
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
607
+ }
608
+ if (this.ended) {
609
+ if (this.buffer.length === 0) return null;
610
+ const rest = this.buffer;
611
+ this.buffer = "";
612
+ return rest;
613
+ }
614
+ const chunk = await this.iterator.next();
615
+ if (chunk.done === true) {
616
+ this.ended = true;
617
+ continue;
618
+ }
619
+ this.buffer += this.decoder.decode(chunk.value, { stream: true });
620
+ }
621
+ }
622
+ }
623
+
624
+ /** A `LineSource` over a fixed script of answers; for tests. */
625
+ export class ScriptedLineSource implements LineSource {
626
+ private index = 0;
627
+
628
+ constructor(private readonly lines: readonly string[]) {}
629
+
630
+ async next(): Promise<string | null> {
631
+ if (this.index >= this.lines.length) return null;
632
+ const line = this.lines[this.index] ?? null;
633
+ this.index += 1;
634
+ return line;
635
+ }
636
+ }