@hraness/dawg 0.0.0-stage → 0.3.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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,1191 @@
1
+ import {
2
+ clearKey,
3
+ isValidKey,
4
+ keyEnvName,
5
+ maskKey,
6
+ readConfig,
7
+ resolveGatewayKey,
8
+ resolveOpenRouterKey,
9
+ storeKey,
10
+ writeConfig,
11
+ type AuthEnv,
12
+ type KeyKind,
13
+ type ProviderChoice,
14
+ type ResolvedKey,
15
+ type SubscriptionSlot,
16
+ } from "./credentials.ts";
17
+ import {
18
+ describeReason,
19
+ readCapabilities,
20
+ resolveXcbBin,
21
+ shortModelLabel,
22
+ XCB_DOC_URL,
23
+ XCB_INSTALL,
24
+ XCB_MIN_VERSION,
25
+ type XcbAccount,
26
+ type XcbModel,
27
+ } from "../agent/xcb.ts";
28
+ import {
29
+ isApiSelection,
30
+ providerLabel,
31
+ selectProvider,
32
+ type ProviderSelection,
33
+ } from "../agent/provider.ts";
34
+ import {
35
+ discover,
36
+ familyCounts,
37
+ LOGIN_OPTIONS,
38
+ OPTION_TITLES,
39
+ type Discovery,
40
+ type LoginOption,
41
+ type OptionStatus,
42
+ type SubscriptionFamily,
43
+ } from "./discover.ts";
44
+ import {
45
+ authorizeUrl,
46
+ checkOpenRouterKey,
47
+ createPkce,
48
+ exchangeCode,
49
+ OAUTH_TIMEOUT_MS,
50
+ OPENROUTER_KEYS_URL,
51
+ startCallbackServer,
52
+ } from "./openrouter.ts";
53
+ import {
54
+ CLASS_TITLES,
55
+ formatPromptCost,
56
+ loadLiveCatalog,
57
+ loadPrices,
58
+ modelAlias,
59
+ modelRows,
60
+ resolveModelChoice,
61
+ type ModelRow,
62
+ } from "../agent/models.ts";
63
+ import type { ApiProvider } from "../agent/gateway.ts";
64
+ import type { ChoiceItem, ChooseOptions } from "./picker.ts";
65
+
66
+ export const KEYS_URL =
67
+ "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys";
68
+ export const VERCEL_INSTALL = "bun add -g vercel # or: npm i -g vercel";
69
+ const DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1";
70
+
71
+ /** Terminal interaction, injectable so flows are testable without a TTY. */
72
+ export type LoginIO = Readonly<{
73
+ print(line: string): void;
74
+ /** Read a line without echo. */
75
+ readSecret(prompt: string): Promise<string>;
76
+ ask(prompt: string): Promise<string>;
77
+ interactive: boolean;
78
+ /**
79
+ * An arrow-key picker; resolves the chosen index or undefined on cancel.
80
+ * Without it, choices are numbered and read with `ask`.
81
+ */
82
+ choose?(
83
+ title: string,
84
+ items: readonly ChoiceItem[],
85
+ options?: ChooseOptions,
86
+ ): Promise<number | undefined>;
87
+ }>;
88
+
89
+ export type LoginDeps = Readonly<{
90
+ auth: AuthEnv;
91
+ io: LoginIO;
92
+ hostname: string;
93
+ fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
94
+ /** Base URL override for OpenRouter (tests). */
95
+ openrouterBaseUrl?: string;
96
+ openrouterAuthUrl?: string;
97
+ /** OAuth wait; defaults to five minutes. */
98
+ oauthTimeoutMs?: number;
99
+ /** Discovery deadline per probe. */
100
+ discoveryTimeoutMs?: number;
101
+ }>;
102
+
103
+ /**
104
+ * `auto` is bare `dawg login`: reuse a working saved choice, else discover
105
+ * and pick. `key` pastes an AI Gateway key; `xcb` is any xcb account.
106
+ */
107
+ export type LoginTarget = "auto" | "pick" | LoginOption | "key" | "xcb";
108
+ /** @deprecated kept for callers of the earlier API. */
109
+ export type LoginMode = LoginTarget;
110
+ export type KeyCheck = "valid" | "rejected" | "unverified";
111
+
112
+ export type LoginOptions = {
113
+ budget?: number;
114
+ xcb?: XcbPreset;
115
+ /** `--account <id>` / `--model <key>` for codex and claude. */
116
+ account?: string;
117
+ model?: string;
118
+ };
119
+
120
+ /** `dawg login [<provider>|--key|--xcb] [--budget <dollars>]` → exit code. */
121
+ export async function login(
122
+ target: LoginTarget,
123
+ deps: LoginDeps,
124
+ options: LoginOptions = {},
125
+ ): Promise<number> {
126
+ if (target === "xcb") return loginXcb(deps, options.xcb);
127
+ if (target === "key") return loginWithPastedKey(deps, "gateway");
128
+ if (target === "gateway") return loginGateway(deps, options);
129
+ if (target === "openrouter") return loginOpenRouter(deps);
130
+ if (target === "codex" || target === "claude")
131
+ return loginSubscription(deps, target, options);
132
+ if (target === "auto") {
133
+ const config = await readConfig(deps.auth);
134
+ if (config.provider && config.provider !== "auto") {
135
+ const selection = await selectProvider(deps.auth);
136
+ if (selection.kind !== "offline") {
137
+ deps.io.print(
138
+ `Signed in · ${providerLabel(selection)}. Switch with \`dawg login <gateway|openrouter|codex|claude>\` or \`dawg model\`.`,
139
+ );
140
+ return 0;
141
+ }
142
+ deps.io.print(`Your saved sign-in stopped working: ${selection.reason}.`);
143
+ }
144
+ }
145
+ return loginPick(deps, options);
146
+ }
147
+
148
+ /** Discover everything, then show the picker (or confirm the one ready option). */
149
+ async function loginPick(
150
+ deps: LoginDeps,
151
+ options: LoginOptions,
152
+ ): Promise<number> {
153
+ const { io } = deps;
154
+ io.print("Looking for existing setups…");
155
+ const found = await discover(deps.auth, {
156
+ refreshXcb: true,
157
+ onProgress: (line) => io.print(line),
158
+ ...(deps.discoveryTimeoutMs !== undefined
159
+ ? { timeoutMs: deps.discoveryTimeoutMs }
160
+ : {}),
161
+ });
162
+ const ready = found.options.filter((option) => option.ready);
163
+ if (!io.interactive) {
164
+ const best = ready[0];
165
+ if (!best) {
166
+ io.print("No AI provider is set up, and this is not a terminal.");
167
+ io.print(
168
+ "Set AI_GATEWAY_API_KEY or OPENROUTER_API_KEY, or run `dawg login` in a terminal.",
169
+ );
170
+ return 1;
171
+ }
172
+ io.print(`Using ${best.title} (${stripCheck(best.summary)}).`);
173
+ return useOption(deps, best.id, found, options);
174
+ }
175
+ if (ready.length === 1) {
176
+ const only = ready[0]!;
177
+ io.print(`Found ${only.title}: ${stripCheck(only.summary)}.`);
178
+ const answer = (await io.ask(`Use ${only.title}? [Y/n] `))
179
+ .trim()
180
+ .toLowerCase();
181
+ if (answer === "" || answer === "y" || answer === "yes")
182
+ return useOption(deps, only.id, found, options);
183
+ }
184
+ const initial = Math.max(
185
+ 0,
186
+ found.options.findIndex((option) => option.ready || option.detected),
187
+ );
188
+ const index = await choose(
189
+ io,
190
+ "Sign in to dawg's agent",
191
+ found.options.map((option, position) => ({
192
+ label: `${option.title}${position === initial ? " (recommended)" : ""}`,
193
+ detail: option.summary,
194
+ })),
195
+ { initial },
196
+ );
197
+ if (index === undefined) {
198
+ io.print("Nothing changed. Direct commands work without signing in.");
199
+ return 1;
200
+ }
201
+ return useOption(deps, found.options[index]!.id, found, options);
202
+ }
203
+
204
+ function stripCheck(summary: string): string {
205
+ return summary.replace(/^✓\s*/, "");
206
+ }
207
+
208
+ async function useOption(
209
+ deps: LoginDeps,
210
+ id: LoginOption,
211
+ found: Discovery,
212
+ options: LoginOptions,
213
+ ): Promise<number> {
214
+ if (id === "gateway") return loginGateway(deps, options, found);
215
+ if (id === "openrouter") return loginOpenRouter(deps, found.openrouterKey);
216
+ return loginSubscription(deps, id, options);
217
+ }
218
+
219
+ /** The arrow-key picker when available, otherwise a numbered prompt. */
220
+ export async function choose(
221
+ io: LoginIO,
222
+ title: string,
223
+ items: readonly ChoiceItem[],
224
+ options: ChooseOptions = {},
225
+ ): Promise<number | undefined> {
226
+ if (io.choose) return io.choose(title, items, options);
227
+ io.print(title);
228
+ let group: string | undefined;
229
+ items.forEach((item, index) => {
230
+ if (item.group && item.group !== group) io.print(` ${item.group}`);
231
+ group = item.group;
232
+ io.print(
233
+ ` ${index + 1}) ${item.current ? "● " : ""}${item.label}${item.detail ? ` — ${item.detail}` : ""}`,
234
+ );
235
+ });
236
+ if (!io.interactive) return undefined;
237
+ const initial = (options.initial ?? 0) + 1;
238
+ const answer = (
239
+ await io.ask(`Choose 1-${items.length} [${initial}]: `)
240
+ ).trim();
241
+ const index = answer ? Number.parseInt(answer, 10) - 1 : initial - 1;
242
+ if (!Number.isInteger(index) || index < 0 || index >= items.length)
243
+ return undefined;
244
+ return items[index]?.disabled ? undefined : index;
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Vercel AI Gateway
249
+
250
+ async function loginGateway(
251
+ deps: LoginDeps,
252
+ options: { budget?: number },
253
+ found?: Discovery,
254
+ ): Promise<number> {
255
+ const { auth, io } = deps;
256
+ const existing = found ? found.gatewayKey : await resolveGatewayKey(auth);
257
+ if (existing && options.budget === undefined)
258
+ return adoptKey(deps, "gateway", existing);
259
+ const vercel = auth.runner.which("vercel");
260
+ if (!vercel) {
261
+ io.print(
262
+ "The Vercel CLI is not installed. Install it to create a key automatically:",
263
+ );
264
+ io.print(` ${VERCEL_INSTALL}`);
265
+ io.print("Or paste an AI Gateway key (Enter opens the key page).");
266
+ return loginWithPastedKey(deps, "gateway");
267
+ }
268
+ let who = await whoami(deps, vercel);
269
+ if (!who) {
270
+ if (!io.interactive) {
271
+ io.print(
272
+ "Not logged in to Vercel. Run `vercel login` first, or re-run `dawg login` in a terminal.",
273
+ );
274
+ return 1;
275
+ }
276
+ io.print(
277
+ "Not logged in to Vercel; starting `vercel login` (opens your browser)…",
278
+ );
279
+ const result = await auth.runner.run(vercel, ["login"], {
280
+ inherit: true,
281
+ timeoutMs: 10 * 60_000,
282
+ });
283
+ if (result.code !== 0) {
284
+ io.print("`vercel login` did not finish. Nothing was saved.");
285
+ return 1;
286
+ }
287
+ who = await whoami(deps, vercel);
288
+ if (!who) {
289
+ io.print("Still not logged in to Vercel. Nothing was saved.");
290
+ return 1;
291
+ }
292
+ }
293
+ io.print(`Vercel: ${who}`);
294
+ const name = `dawg-${safeHostname(deps.hostname)}`;
295
+ const args = [
296
+ "ai-gateway",
297
+ "api-keys",
298
+ "create",
299
+ "--name",
300
+ name,
301
+ "--non-interactive",
302
+ ];
303
+ if (options.budget !== undefined)
304
+ args.push("--limit", String(options.budget));
305
+ io.print(
306
+ `Creating AI Gateway key "${name}"${options.budget !== undefined ? ` with a $${options.budget} limit` : ""}…`,
307
+ );
308
+ const created = await auth.runner.run(vercel, args, {
309
+ timeoutMs: 60_000,
310
+ maxOutputBytes: 64 * 1024,
311
+ });
312
+ // The CLI prints only the key on stdout; status lines go to stderr.
313
+ const key = created.stdout
314
+ .split(/\r?\n/)
315
+ .map((line) => line.trim())
316
+ .find((line) => isValidKey(line));
317
+ if (created.code !== 0 || !key) {
318
+ const detail = created.stderr.replace(/\s+/g, " ").trim().slice(0, 200);
319
+ io.print(
320
+ `Could not create a key${detail ? `: ${redactKeys(detail)}` : ` (exit ${created.code})`}.`,
321
+ );
322
+ io.print("You can paste one instead with `dawg login --key`.");
323
+ return 1;
324
+ }
325
+ return saveAndReport(deps, "gateway", key, "created");
326
+ }
327
+
328
+ async function whoami(
329
+ deps: LoginDeps,
330
+ vercel: string,
331
+ ): Promise<string | undefined> {
332
+ const result = await deps.auth.runner
333
+ .run(vercel, ["whoami", "--format", "json", "--non-interactive"], {
334
+ timeoutMs: 20_000,
335
+ maxOutputBytes: 64 * 1024,
336
+ })
337
+ .catch(() => undefined);
338
+ if (!result || result.code !== 0) return undefined;
339
+ try {
340
+ const parsed: unknown = JSON.parse(result.stdout);
341
+ if (typeof parsed !== "object" || parsed === null) return undefined;
342
+ const record = parsed as Record<string, unknown>;
343
+ const username =
344
+ typeof record.username === "string" ? record.username : undefined;
345
+ if (!username || !/^[\w.@+-]{1,64}$/.test(username)) return undefined;
346
+ const team = record.team as Record<string, unknown> | undefined;
347
+ const slug =
348
+ team && typeof team.slug === "string" && /^[\w.-]{1,64}$/.test(team.slug)
349
+ ? team.slug
350
+ : undefined;
351
+ return slug ? `${username} (team ${slug})` : username;
352
+ } catch {
353
+ return undefined;
354
+ }
355
+ }
356
+
357
+ const SERVICE_NAMES: Readonly<Record<KeyKind, string>> = Object.freeze({
358
+ gateway: "AI Gateway",
359
+ openrouter: "OpenRouter",
360
+ });
361
+
362
+ /** A key that already exists (env or stored): validate it and select it. */
363
+ async function adoptKey(
364
+ deps: LoginDeps,
365
+ kind: KeyKind,
366
+ key: ResolvedKey,
367
+ ): Promise<number> {
368
+ const { io } = deps;
369
+ const check = await checkServiceKey(deps, kind, key.key);
370
+ const where =
371
+ key.source === "env" ? keyEnvName(kind) : `saved key ${maskKey(key.key)}`;
372
+ if (check === "rejected") {
373
+ io.print(`✗ ${SERVICE_NAMES[kind]} rejected the ${where}.`);
374
+ if (key.source === "env") {
375
+ io.print(`Unset ${keyEnvName(kind)} or replace it, then try again.`);
376
+ return 1;
377
+ }
378
+ io.print("Let's replace it.");
379
+ return kind === "openrouter"
380
+ ? loginOpenRouter(deps, undefined)
381
+ : loginWithPastedKey(deps, "gateway");
382
+ }
383
+ await selectService(deps, kind);
384
+ io.print(
385
+ `✓ Using ${SERVICE_NAMES[kind]} with ${where}${check === "unverified" ? " (could not verify offline)" : ""}.`,
386
+ );
387
+ return 0;
388
+ }
389
+
390
+ async function selectService(deps: LoginDeps, kind: KeyKind): Promise<void> {
391
+ await writeConfig(deps.auth, { provider: kind, setup: undefined });
392
+ const selection = await selectProvider(deps.auth);
393
+ if (isApiSelection(selection))
394
+ deps.io.print(
395
+ `Model: ${modelAlias(kind, selection.modelId)} · change with \`dawg model\` or /model.`,
396
+ );
397
+ }
398
+
399
+ async function loginWithPastedKey(
400
+ deps: LoginDeps,
401
+ kind: KeyKind,
402
+ ): Promise<number> {
403
+ const { io } = deps;
404
+ const service = SERVICE_NAMES[kind];
405
+ const page = kind === "gateway" ? KEYS_URL : OPENROUTER_KEYS_URL;
406
+ if (!io.interactive) {
407
+ io.print(
408
+ `Pasting a key needs a terminal. Or set ${keyEnvName(kind)} in your environment.`,
409
+ );
410
+ return 1;
411
+ }
412
+ io.print(`Create a key at ${page}`);
413
+ for (let attempt = 0; attempt < 3; attempt += 1) {
414
+ const entered = (
415
+ await io.readSecret(
416
+ `${service} key (input hidden, Enter to open the key page): `,
417
+ )
418
+ ).trim();
419
+ if (!entered) {
420
+ await openUrl(deps, page, "the key page");
421
+ continue;
422
+ }
423
+ if (!isValidKey(entered)) {
424
+ io.print(
425
+ `That does not look like an ${service} key (letters, digits, - _ . only).`,
426
+ );
427
+ continue;
428
+ }
429
+ const check = await checkServiceKey(deps, kind, entered);
430
+ if (check === "rejected") {
431
+ io.print(`${service} rejected that key. Nothing was saved.`);
432
+ continue;
433
+ }
434
+ return saveAndReport(deps, kind, entered, "pasted", check);
435
+ }
436
+ io.print("No key saved.");
437
+ return 1;
438
+ }
439
+
440
+ async function saveAndReport(
441
+ deps: LoginDeps,
442
+ kind: KeyKind,
443
+ key: string,
444
+ how: "created" | "pasted" | "authorized",
445
+ knownCheck?: KeyCheck,
446
+ ): Promise<number> {
447
+ const { auth, io } = deps;
448
+ const service = SERVICE_NAMES[kind];
449
+ const where = await storeKey(auth, kind, key);
450
+ io.print(
451
+ `Saved ${how} key ${maskKey(key)} to ${where === "keychain" ? "the macOS Keychain" : "~/.config/dawg/credentials.json"}.`,
452
+ );
453
+ const check = knownCheck ?? (await checkServiceKey(deps, kind, key));
454
+ io.print(
455
+ check === "valid"
456
+ ? `✓ ${service} accepted the key.`
457
+ : check === "rejected"
458
+ ? `✗ ${service} rejected the key (new keys can take a moment; check with \`dawg auth status --check\`).`
459
+ : `? Could not reach ${service} to verify the key; it is saved and will be used when online.`,
460
+ );
461
+ if (check === "rejected") return 1;
462
+ await selectService(deps, kind);
463
+ if (auth.env[keyEnvName(kind)])
464
+ io.print(
465
+ `Note: ${keyEnvName(kind)} is set in your environment and takes precedence.`,
466
+ );
467
+ io.print("Run `dawg` and type a request.");
468
+ return 0;
469
+ }
470
+
471
+ function checkServiceKey(
472
+ deps: Pick<LoginDeps, "auth" | "fetcher" | "openrouterBaseUrl">,
473
+ kind: KeyKind,
474
+ key: string,
475
+ ): Promise<KeyCheck> {
476
+ if (kind === "openrouter")
477
+ return checkOpenRouterKey(key, {
478
+ ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),
479
+ ...openrouterBase(deps),
480
+ });
481
+ return checkKey(deps, key);
482
+ }
483
+
484
+ function openrouterBase(deps: Pick<LoginDeps, "auth" | "openrouterBaseUrl">): {
485
+ baseUrl?: string;
486
+ } {
487
+ const base = deps.openrouterBaseUrl ?? deps.auth.env.OPENROUTER_BASE_URL;
488
+ return base ? { baseUrl: base } : {};
489
+ }
490
+
491
+ /** A cheap authenticated request (`GET /v1/credits`). */
492
+ export async function checkKey(
493
+ deps: Pick<LoginDeps, "auth" | "fetcher">,
494
+ key: string,
495
+ ): Promise<KeyCheck> {
496
+ const fetcher = deps.fetcher ?? fetch;
497
+ const base = (deps.auth.env.AI_GATEWAY_BASE_URL || DEFAULT_BASE_URL).replace(
498
+ /\/$/,
499
+ "",
500
+ );
501
+ try {
502
+ const response = await fetcher(`${base}/credits`, {
503
+ headers: { authorization: `Bearer ${key}` },
504
+ signal: AbortSignal.timeout(8_000),
505
+ });
506
+ await response.body?.cancel().catch(() => undefined);
507
+ if (response.ok) return "valid";
508
+ if (response.status === 401 || response.status === 403) return "rejected";
509
+ return "unverified";
510
+ } catch {
511
+ return "unverified";
512
+ }
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // OpenRouter (OAuth PKCE)
517
+
518
+ async function loginOpenRouter(
519
+ deps: LoginDeps,
520
+ existing?: ResolvedKey,
521
+ ): Promise<number> {
522
+ const { io } = deps;
523
+ const found = existing ?? (await resolveOpenRouterKey(deps.auth));
524
+ if (found) return adoptKey(deps, "openrouter", found);
525
+ if (!io.interactive) {
526
+ io.print(
527
+ "OpenRouter sign-in opens a browser, so it needs a terminal. Or set OPENROUTER_API_KEY.",
528
+ );
529
+ return 1;
530
+ }
531
+ const pkce = createPkce();
532
+ const timeoutMs = deps.oauthTimeoutMs ?? OAUTH_TIMEOUT_MS;
533
+ const server = startCallbackServer(pkce.state, { timeoutMs });
534
+ try {
535
+ // OpenRouter returns `state` unchanged next to `code` on the callback.
536
+ const url = authorizeUrl(server.url, pkce, deps.openrouterAuthUrl);
537
+ io.print("Sign in to OpenRouter in your browser to connect dawg.");
538
+ await openUrl(deps, url, "OpenRouter");
539
+ io.print(`If the browser did not open, visit:\n ${url}`);
540
+ io.print(
541
+ `Waiting up to ${Math.round(timeoutMs / 60_000)} min… (Ctrl-C to cancel)`,
542
+ );
543
+ let code: string;
544
+ try {
545
+ code = await server.code;
546
+ } catch (error) {
547
+ io.print(
548
+ `OpenRouter sign-in did not finish: ${error instanceof Error ? error.message : String(error)}.`,
549
+ );
550
+ io.print("You can paste a key instead.");
551
+ return loginWithPastedKey(deps, "openrouter");
552
+ }
553
+ const key = await exchangeCode(code, pkce, {
554
+ ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),
555
+ ...openrouterBase(deps),
556
+ });
557
+ return saveAndReport(deps, "openrouter", key, "authorized");
558
+ } finally {
559
+ server.stop();
560
+ }
561
+ }
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // Codex / Claude subscriptions through xcb
565
+
566
+ /** A pre-chosen xcb account and model (from the TUI picker). */
567
+ export type XcbPreset = Readonly<{ account: string; model: string }>;
568
+ export type XcbChoice = Readonly<{ account: XcbAccount; model: XcbModel }>;
569
+
570
+ /**
571
+ * Every usable (account, model) pair xcb reports, without printing. An
572
+ * account is usable iff `available`; models with no admission are skipped.
573
+ */
574
+ export async function listXcbChoices(
575
+ auth: AuthEnv,
576
+ family?: string,
577
+ ): Promise<XcbChoice[] | undefined> {
578
+ const bin = resolveXcbBin(auth.env, auth.runner);
579
+ if (!bin) return undefined;
580
+ try {
581
+ return usableChoices(
582
+ (await readCapabilities(bin, auth.runner)).accounts,
583
+ family,
584
+ );
585
+ } catch {
586
+ return undefined;
587
+ }
588
+ }
589
+
590
+ function usableChoices(
591
+ accounts: readonly XcbAccount[],
592
+ family?: string,
593
+ ): XcbChoice[] {
594
+ return accounts
595
+ .filter(
596
+ (account) =>
597
+ account.available &&
598
+ (family === undefined || account.provider === family),
599
+ )
600
+ .sort(
601
+ (a, b) =>
602
+ Number(a.admission === "pending") - Number(b.admission === "pending"),
603
+ )
604
+ .flatMap((account) => account.models.map((model) => ({ account, model })));
605
+ }
606
+
607
+ const FAMILY_TITLES: Readonly<Record<SubscriptionFamily, string>> = {
608
+ codex: OPTION_TITLES.codex,
609
+ claude: OPTION_TITLES.claude,
610
+ };
611
+
612
+ async function loginSubscription(
613
+ deps: LoginDeps,
614
+ family: SubscriptionFamily,
615
+ options: LoginOptions,
616
+ ): Promise<number> {
617
+ const { auth, io } = deps;
618
+ const bin = resolveXcbBin(auth.env, auth.runner);
619
+ if (!bin) {
620
+ io.print(
621
+ `dawg reaches your ${FAMILY_TITLES[family]} through xcb, which is not installed.`,
622
+ );
623
+ io.print(` Install: ${XCB_INSTALL}`);
624
+ io.print(` Docs: ${XCB_DOC_URL}`);
625
+ io.print(`Then run \`dawg login ${family}\` again (or set XCB_BIN).`);
626
+ return 1;
627
+ }
628
+ const read = async (): Promise<XcbAccount[] | undefined> => {
629
+ const found = await discover(auth, {
630
+ refreshXcb: true,
631
+ onProgress: (line) => io.print(line),
632
+ ...(deps.discoveryTimeoutMs !== undefined
633
+ ? { timeoutMs: deps.discoveryTimeoutMs }
634
+ : {}),
635
+ });
636
+ if (found.xcb.outdated)
637
+ io.print(
638
+ `xcb ${found.xcb.version ?? "?"} is installed; upgrade xcb to ${XCB_MIN_VERSION}+ (\`xcb upgrade\`).`,
639
+ );
640
+ if (found.xcb.state !== "ok") return undefined;
641
+ return found.xcb.accounts.flatMap((row) =>
642
+ row.account ? [row.account] : [],
643
+ );
644
+ };
645
+ let accounts = await read();
646
+ if (!accounts) {
647
+ io.print("Could not read xcb's accounts. Check `xcb accounts list`.");
648
+ return 1;
649
+ }
650
+ let choices = usableChoices(accounts, family);
651
+ if (choices.length === 0) {
652
+ const mine = accounts.filter((account) => account.provider === family);
653
+ for (const account of mine.slice(0, 8))
654
+ io.print(
655
+ ` ${account.label} · ${describeReason(account.reason).replace("<account>", account.id)}`,
656
+ );
657
+ const setup = `xcb setup ${family}${mine.length > 0 ? " --new" : ""}`;
658
+ if (!io.interactive) {
659
+ io.print(
660
+ `No ${FAMILY_TITLES[family]} is ready. Run \`${setup}\`, then \`dawg login ${family}\`.`,
661
+ );
662
+ return 1;
663
+ }
664
+ const answer = (
665
+ await io.ask(
666
+ `No ${FAMILY_TITLES[family]} is ready. Sign in now with \`${setup}\`? [Y/n] `,
667
+ )
668
+ )
669
+ .trim()
670
+ .toLowerCase();
671
+ if (answer !== "" && answer !== "y" && answer !== "yes") {
672
+ io.print(`Run \`${setup}\` when ready, then \`dawg login ${family}\`.`);
673
+ return 1;
674
+ }
675
+ const result = await auth.runner.run(bin, setup.split(" ").slice(1), {
676
+ inherit: true,
677
+ timeoutMs: 15 * 60_000,
678
+ });
679
+ if (result.code !== 0) {
680
+ io.print(`\`${setup}\` did not finish. Nothing was saved.`);
681
+ return 1;
682
+ }
683
+ accounts = (await read()) ?? [];
684
+ choices = usableChoices(accounts, family);
685
+ if (choices.length === 0) {
686
+ io.print(
687
+ `xcb still lists no usable ${family} account. Check \`xcb accounts list\`.`,
688
+ );
689
+ return 1;
690
+ }
691
+ }
692
+ const picked = await pickXcbChoice(
693
+ deps,
694
+ choices,
695
+ FAMILY_TITLES[family],
696
+ options.account,
697
+ options.model,
698
+ );
699
+ if (!picked) return 1;
700
+ return saveXcb(deps, family, picked);
701
+ }
702
+
703
+ async function pickXcbChoice(
704
+ deps: LoginDeps,
705
+ choices: readonly XcbChoice[],
706
+ title: string,
707
+ account?: string,
708
+ model?: string,
709
+ ): Promise<XcbChoice | undefined> {
710
+ const { io } = deps;
711
+ let pool = choices;
712
+ if (account) {
713
+ pool = pool.filter(
714
+ (choice) =>
715
+ choice.account.id === account || choice.account.label === account,
716
+ );
717
+ if (pool.length === 0) {
718
+ io.print(
719
+ `No usable account "${account.slice(0, 40)}". Nothing was saved.`,
720
+ );
721
+ return undefined;
722
+ }
723
+ }
724
+ if (model) {
725
+ pool = pool.filter((choice) => choice.model.key === model);
726
+ if (pool.length === 0) {
727
+ io.print(
728
+ `That account has no model "${model.slice(0, 60)}". Nothing was saved.`,
729
+ );
730
+ return undefined;
731
+ }
732
+ }
733
+ // One account per row first, then its models.
734
+ const accounts = [
735
+ ...new Map(
736
+ pool.map((choice) => [choice.account.id, choice.account]),
737
+ ).values(),
738
+ ];
739
+ let chosenAccount = accounts[0]!;
740
+ if (accounts.length > 1 && io.interactive) {
741
+ const index = await choose(
742
+ io,
743
+ `${title} · choose an account`,
744
+ accounts.map((row) => ({
745
+ label: row.label,
746
+ detail: `${row.models.length} models${row.admission === "pending" ? " · admits on first use" : ""}`,
747
+ })),
748
+ );
749
+ if (index === undefined) {
750
+ io.print("No account selected. Nothing was saved.");
751
+ return undefined;
752
+ }
753
+ chosenAccount = accounts[index]!;
754
+ }
755
+ const models = pool.filter(
756
+ (choice) => choice.account.id === chosenAccount.id,
757
+ );
758
+ let chosen = models[0]!;
759
+ if (models.length > 1 && io.interactive) {
760
+ const index = await choose(
761
+ io,
762
+ `${chosenAccount.label} · choose a model`,
763
+ models.map((choice) => ({
764
+ label: shortModelLabel(choice.model.key),
765
+ detail: `${choice.model.label === choice.model.key ? "" : `${choice.model.label} · `}included`,
766
+ })),
767
+ { filter: models.length > 8 },
768
+ );
769
+ if (index === undefined) {
770
+ io.print("No model selected. Nothing was saved.");
771
+ return undefined;
772
+ }
773
+ chosen = models[index]!;
774
+ }
775
+ return chosen;
776
+ }
777
+
778
+ async function saveXcb(
779
+ deps: LoginDeps,
780
+ slot: SubscriptionSlot,
781
+ picked: XcbChoice,
782
+ ): Promise<number> {
783
+ const { io } = deps;
784
+ await writeConfig(deps.auth, {
785
+ provider: slot,
786
+ [slot]: { account: picked.account.id, model: picked.model.key },
787
+ setup: undefined,
788
+ });
789
+ io.print(
790
+ `✓ Using ${picked.account.label} (${picked.account.provider}) · ${shortModelLabel(picked.model.key)} through xcb.`,
791
+ );
792
+ if (picked.account.admission === "pending")
793
+ io.print(
794
+ "xcb admits this account on its first request, so the first turn can take up to a minute longer.",
795
+ );
796
+ io.print(
797
+ "Saved to ~/.config/dawg/config.json (no secrets). Run `dawg` and type a request.",
798
+ );
799
+ return 0;
800
+ }
801
+
802
+ /** `dawg login --xcb`: any xcb account (Devin included). */
803
+ async function loginXcb(deps: LoginDeps, preset?: XcbPreset): Promise<number> {
804
+ const { auth, io } = deps;
805
+ const bin = resolveXcbBin(auth.env, auth.runner);
806
+ if (!bin) {
807
+ io.print(
808
+ "xcb is not installed. It routes dawg's requests to your Claude, Codex or Devin subscription.",
809
+ );
810
+ io.print(` Install: ${XCB_INSTALL}`);
811
+ io.print(` Docs: ${XCB_DOC_URL}`);
812
+ io.print("Then run `dawg login --xcb` again (or set XCB_BIN to its path).");
813
+ return 1;
814
+ }
815
+ let accounts: readonly XcbAccount[];
816
+ try {
817
+ accounts = (await readCapabilities(bin, auth.runner)).accounts;
818
+ } catch (error) {
819
+ io.print(
820
+ `Could not read xcb capabilities: ${error instanceof Error ? error.message : String(error)}`,
821
+ );
822
+ return 1;
823
+ }
824
+ const choices = usableChoices(accounts);
825
+ if (choices.length === 0) {
826
+ io.print("No xcb account is available for applications yet.");
827
+ for (const account of accounts.slice(0, 12))
828
+ io.print(
829
+ ` ${account.label} · ${account.provider} · ${describeReason(account.reason).replace("<account>", account.id)}`,
830
+ );
831
+ if (accounts.length === 0)
832
+ io.print(
833
+ "No accounts found; add one with `xcb setup claude` or `xcb setup codex`.",
834
+ );
835
+ return 1;
836
+ }
837
+ if (preset) {
838
+ const match = choices.find(
839
+ (choice) =>
840
+ choice.account.id === preset.account &&
841
+ choice.model.key === preset.model,
842
+ );
843
+ if (!match) {
844
+ io.print(
845
+ `xcb account ${preset.account.slice(0, 24)} · ${preset.model.slice(0, 60)} is no longer available. Nothing was saved.`,
846
+ );
847
+ return 1;
848
+ }
849
+ return saveXcb(deps, slotFor(match.account.provider), match);
850
+ }
851
+ const picked = await pickXcbChoice(deps, choices, "xcb");
852
+ if (!picked) return 1;
853
+ return saveXcb(deps, slotFor(picked.account.provider), picked);
854
+ }
855
+
856
+ function slotFor(provider: string): SubscriptionSlot {
857
+ return provider === "codex" || provider === "claude" ? provider : "xcb";
858
+ }
859
+
860
+ // ---------------------------------------------------------------------------
861
+ // Model picker
862
+
863
+ export type ModelChoice = Readonly<{
864
+ label: string;
865
+ detail: string;
866
+ group: string;
867
+ current: boolean;
868
+ /** Saved with `saveModelChoice`. */
869
+ value: string;
870
+ }>;
871
+
872
+ /**
873
+ * The model list for the active provider: curated frontier/fast/open rows
874
+ * the live catalog serves with tools, each with its estimated cost per
875
+ * prompt; for a subscription, the models xcb lists for that account.
876
+ */
877
+ export async function modelChoices(
878
+ deps: Pick<LoginDeps, "auth" | "fetcher" | "openrouterBaseUrl">,
879
+ selection: ProviderSelection,
880
+ ): Promise<ModelChoice[]> {
881
+ if (isApiSelection(selection)) {
882
+ const common = {
883
+ configDir: deps.auth.dir,
884
+ ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),
885
+ // Tests (and mirrors) can point pricing elsewhere.
886
+ ...(deps.auth.env.DAWG_MODELS_DEV_URL
887
+ ? { url: deps.auth.env.DAWG_MODELS_DEV_URL }
888
+ : {}),
889
+ };
890
+ const [catalog, prices] = await Promise.all([
891
+ loadLiveCatalog(selection.kind, {
892
+ ...common,
893
+ apiKey: selection.apiKey,
894
+ ...(selection.kind === "openrouter"
895
+ ? openrouterBase(deps)
896
+ : deps.auth.env.AI_GATEWAY_BASE_URL
897
+ ? { baseUrl: deps.auth.env.AI_GATEWAY_BASE_URL }
898
+ : {}),
899
+ }),
900
+ loadPrices(common),
901
+ ]);
902
+ return modelRows(selection.kind, {
903
+ current: selection.modelId,
904
+ ...(catalog ? { catalog } : {}),
905
+ prices,
906
+ }).map((row: ModelRow) => ({
907
+ label: row.alias,
908
+ detail: `${formatPromptCost(row.costUsd)} ${row.label}`,
909
+ group: CLASS_TITLES[row.class],
910
+ current: row.current,
911
+ value: row.id,
912
+ }));
913
+ }
914
+ if (selection.kind === "xcb") {
915
+ const capabilities = await readCapabilities(
916
+ selection.bin,
917
+ deps.auth.runner,
918
+ );
919
+ const account = capabilities.accounts.find(
920
+ (row) => row.id === selection.account,
921
+ );
922
+ return (account?.available ? account.models : []).map((model) => ({
923
+ label: shortModelLabel(model.key),
924
+ detail:
925
+ `included ${model.label === model.key ? "" : model.label}`.trimEnd(),
926
+ group: account!.label,
927
+ current: model.key === selection.model,
928
+ value: model.key,
929
+ }));
930
+ }
931
+ return [];
932
+ }
933
+
934
+ /** Persist a model for the active provider; returns the new label. */
935
+ export async function saveModelChoice(
936
+ auth: AuthEnv,
937
+ selection: ProviderSelection,
938
+ value: string,
939
+ ): Promise<string> {
940
+ if (isApiSelection(selection)) {
941
+ const id = resolveModelChoice(selection.kind, value);
942
+ if (!id) throw new Error(`unknown model "${value.slice(0, 40)}"`);
943
+ await writeConfig(auth, {
944
+ provider: selection.kind,
945
+ [selection.kind === "openrouter" ? "openrouterModel" : "gatewayModel"]:
946
+ id,
947
+ });
948
+ return `${modelAlias(selection.kind, id)} · ${selection.kind}`;
949
+ }
950
+ if (selection.kind === "xcb") {
951
+ const slot = slotFor(selection.family);
952
+ await writeConfig(auth, {
953
+ provider: slot,
954
+ [slot]: { account: selection.account, model: value },
955
+ });
956
+ return `${shortModelLabel(value)} · ${selection.family}`;
957
+ }
958
+ throw new Error(selection.reason);
959
+ }
960
+
961
+ /** `dawg model [<alias>|<vendor/model>]` → exit code. */
962
+ export async function modelCommand(
963
+ deps: LoginDeps,
964
+ arg?: string,
965
+ ): Promise<number> {
966
+ const { io, auth } = deps;
967
+ const selection = await selectProvider(auth);
968
+ if (selection.kind === "offline") {
969
+ io.print(`No model available: ${selection.reason}.`);
970
+ return 1;
971
+ }
972
+ const rows = await modelChoices(deps, selection);
973
+ if (arg) {
974
+ const value = isApiSelection(selection)
975
+ ? resolveModelChoice(selection.kind, arg)
976
+ : rows.find((row) => row.value === arg || row.label === arg)?.value;
977
+ if (!value) {
978
+ io.print(`Unknown model "${arg.slice(0, 40)}". Choices:`);
979
+ for (const row of rows) io.print(` ${row.label} ${row.detail}`);
980
+ return 2;
981
+ }
982
+ io.print(`model · ${await saveModelChoice(auth, selection, value)}`);
983
+ return 0;
984
+ }
985
+ if (rows.length === 0) {
986
+ io.print("The provider lists no models right now.");
987
+ return 1;
988
+ }
989
+ const index = await choose(
990
+ io,
991
+ `Model · ${providerLabel(selection)}`,
992
+ rows.map((row) => ({
993
+ label: row.label,
994
+ detail: row.detail,
995
+ group: row.group,
996
+ current: row.current,
997
+ })),
998
+ {
999
+ initial: Math.max(
1000
+ 0,
1001
+ rows.findIndex((row) => row.current),
1002
+ ),
1003
+ filter: true,
1004
+ },
1005
+ );
1006
+ if (index === undefined) {
1007
+ io.print(`model · ${providerLabel(selection)} (unchanged)`);
1008
+ return io.interactive ? 0 : 0;
1009
+ }
1010
+ io.print(
1011
+ `model · ${await saveModelChoice(auth, selection, rows[index]!.value)}`,
1012
+ );
1013
+ return 0;
1014
+ }
1015
+
1016
+ // ---------------------------------------------------------------------------
1017
+ // Logout and status
1018
+
1019
+ const LOGOUT_TARGETS = Object.freeze([...LOGIN_OPTIONS, "xcb"] as const);
1020
+ export type LogoutTarget = (typeof LOGOUT_TARGETS)[number];
1021
+ export function isLogoutTarget(value: unknown): value is LogoutTarget {
1022
+ return (LOGOUT_TARGETS as readonly unknown[]).includes(value);
1023
+ }
1024
+
1025
+ /**
1026
+ * `dawg logout [provider]`: with no provider, remove every stored key and
1027
+ * the saved choice; with one, remove only that provider's key or pick (and
1028
+ * the saved choice when it pointed there).
1029
+ */
1030
+ export async function logout(
1031
+ deps: Pick<LoginDeps, "auth" | "io">,
1032
+ target?: LogoutTarget,
1033
+ ): Promise<number> {
1034
+ const { auth, io } = deps;
1035
+ const config = await readConfig(auth);
1036
+ const kinds: KeyKind[] = target
1037
+ ? target === "gateway" || target === "openrouter"
1038
+ ? [target]
1039
+ : []
1040
+ : ["gateway", "openrouter"];
1041
+ for (const kind of kinds) {
1042
+ const removed = await clearKey(auth, kind);
1043
+ io.print(
1044
+ removed.length > 0
1045
+ ? `Removed the stored ${SERVICE_NAMES[kind]} key (${removed.join(", ")}).`
1046
+ : `No stored ${SERVICE_NAMES[kind]} key.`,
1047
+ );
1048
+ if (auth.env[keyEnvName(kind)])
1049
+ io.print(`${keyEnvName(kind)} is still set in your environment.`);
1050
+ }
1051
+ const slots: SubscriptionSlot[] = target
1052
+ ? target === "codex" || target === "claude" || target === "xcb"
1053
+ ? [target]
1054
+ : []
1055
+ : ["codex", "claude", "xcb"];
1056
+ const patch: Record<string, undefined> = {};
1057
+ for (const slot of slots) {
1058
+ if (config[slot])
1059
+ io.print(
1060
+ `Forgot the ${slot} account choice (xcb keeps its own sign-in).`,
1061
+ );
1062
+ patch[slot] = undefined;
1063
+ }
1064
+ if (!target || config.provider === target) {
1065
+ patch.provider = undefined;
1066
+ patch.gatewayModel = undefined;
1067
+ patch.openrouterModel = undefined;
1068
+ io.print("Cleared the saved provider and model; `dawg login` asks again.");
1069
+ } else if (target === "gateway") patch.gatewayModel = undefined;
1070
+ else if (target === "openrouter") patch.openrouterModel = undefined;
1071
+ patch.setup = undefined;
1072
+ await writeConfig(auth, patch);
1073
+ return 0;
1074
+ }
1075
+
1076
+ /**
1077
+ * `dawg auth status` / `/auth`: each option's detected state, which one is
1078
+ * active, and (with `verify`) whether its key is accepted. Never prints a
1079
+ * key, only its mask.
1080
+ */
1081
+ export async function authStatus(
1082
+ deps: Pick<
1083
+ LoginDeps,
1084
+ "auth" | "fetcher" | "openrouterBaseUrl" | "discoveryTimeoutMs"
1085
+ >,
1086
+ options: {
1087
+ verify?: boolean;
1088
+ refreshXcb?: boolean;
1089
+ onProgress?: (line: string) => void;
1090
+ } = {},
1091
+ ): Promise<string[]> {
1092
+ const { auth } = deps;
1093
+ const [selection, config, found] = await Promise.all([
1094
+ selectProvider(auth),
1095
+ readConfig(auth),
1096
+ discover(auth, {
1097
+ refreshXcb: options.refreshXcb ?? true,
1098
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
1099
+ ...(deps.discoveryTimeoutMs !== undefined
1100
+ ? { timeoutMs: deps.discoveryTimeoutMs }
1101
+ : {}),
1102
+ }),
1103
+ ]);
1104
+ const active = activeOption(selection);
1105
+ const lines: string[] = [
1106
+ `active: ${providerLabel(selection)}${
1107
+ auth.env.DAWG_PROVIDER
1108
+ ? " (from DAWG_PROVIDER)"
1109
+ : config.provider
1110
+ ? " (saved)"
1111
+ : selection.kind === "offline"
1112
+ ? ""
1113
+ : " (auto)"
1114
+ }${selection.kind === "offline" ? ` · ${selection.reason}` : ""}`,
1115
+ ];
1116
+ for (const option of found.options) {
1117
+ let check = "";
1118
+ if (
1119
+ options.verify &&
1120
+ (option.id === "gateway" || option.id === "openrouter")
1121
+ ) {
1122
+ const key =
1123
+ option.id === "gateway" ? found.gatewayKey : found.openrouterKey;
1124
+ if (key) check = ` · ${await checkServiceKey(deps, option.id, key.key)}`;
1125
+ }
1126
+ lines.push(
1127
+ `${option.id === active ? "●" : " "} ${option.title.padEnd(28)} ${option.summary}${check}`,
1128
+ );
1129
+ }
1130
+ lines.push(xcbStatusLine(found));
1131
+ return lines;
1132
+ }
1133
+
1134
+ function activeOption(selection: ProviderSelection): LoginOption | undefined {
1135
+ if (isApiSelection(selection)) return selection.kind;
1136
+ if (
1137
+ selection.kind === "xcb" &&
1138
+ (selection.family === "codex" || selection.family === "claude")
1139
+ )
1140
+ return selection.family;
1141
+ return undefined;
1142
+ }
1143
+
1144
+ function xcbStatusLine(found: Discovery): string {
1145
+ const { xcb } = found;
1146
+ if (xcb.state === "missing") return `xcb: not installed (${XCB_INSTALL})`;
1147
+ const version = xcb.version ? ` ${xcb.version}` : "";
1148
+ const upgrade = xcb.outdated ? ` · upgrade xcb to ${XCB_MIN_VERSION}+` : "";
1149
+ if (xcb.state !== "ok") return `xcb${version}: ${xcb.state}${upgrade}`;
1150
+ const families = (["codex", "claude"] as const).map((family) => {
1151
+ const counts = familyCounts(xcb, family);
1152
+ return `${family} ${counts.ready} ready, ${counts.pending} pending, ${counts.unavailable + counts["signed-out"]} unavailable`;
1153
+ });
1154
+ return `xcb${version}: ${families.join(" · ")}${upgrade}`;
1155
+ }
1156
+
1157
+ /** The picker's option statuses, for callers outside the login flow. */
1158
+ export function optionLines(options: readonly OptionStatus[]): string[] {
1159
+ return options.map((option) => `${option.title}: ${option.summary}`);
1160
+ }
1161
+
1162
+ async function openUrl(
1163
+ deps: Pick<LoginDeps, "auth" | "io">,
1164
+ url: string,
1165
+ what: string,
1166
+ ): Promise<void> {
1167
+ const opener = deps.auth.platform === "darwin" ? "open" : "xdg-open";
1168
+ if (!deps.auth.runner.which(opener)) return;
1169
+ const result = await deps.auth.runner
1170
+ .run(opener, [url], { timeoutMs: 10_000 })
1171
+ .catch(() => undefined);
1172
+ if (result?.code === 0) deps.io.print(`Opened ${what} in your browser.`);
1173
+ }
1174
+
1175
+ export function safeHostname(hostname: string): string {
1176
+ const cleaned = hostname
1177
+ .toLowerCase()
1178
+ .replace(/\.local$/, "")
1179
+ .replace(/[^a-z0-9-]+/g, "-")
1180
+ .replace(/^-+|-+$/g, "")
1181
+ .slice(0, 40);
1182
+ return cleaned || "host";
1183
+ }
1184
+
1185
+ function redactKeys(text: string): string {
1186
+ return text
1187
+ .replace(/\bvck_[A-Za-z0-9._-]+/g, "vck_[redacted]")
1188
+ .replace(/\bsk-or-[A-Za-z0-9._-]+/g, "sk-or-[redacted]");
1189
+ }
1190
+
1191
+ export type { ProviderChoice };