@hraness/dawg 0.2.0 → 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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
package/src/auth/login.ts CHANGED
@@ -1,22 +1,67 @@
1
1
  import {
2
- clearGatewayKey,
2
+ clearKey,
3
3
  isValidKey,
4
+ keyEnvName,
4
5
  maskKey,
5
6
  readConfig,
6
7
  resolveGatewayKey,
7
- storeGatewayKey,
8
+ resolveOpenRouterKey,
9
+ storeKey,
8
10
  writeConfig,
9
11
  type AuthEnv,
12
+ type KeyKind,
13
+ type ProviderChoice,
14
+ type ResolvedKey,
15
+ type SubscriptionSlot,
10
16
  } from "./credentials.ts";
11
17
  import {
18
+ describeReason,
12
19
  readCapabilities,
13
20
  resolveXcbBin,
21
+ shortModelLabel,
14
22
  XCB_DOC_URL,
15
23
  XCB_INSTALL,
24
+ XCB_MIN_VERSION,
16
25
  type XcbAccount,
17
26
  type XcbModel,
18
27
  } from "../agent/xcb.ts";
19
- import { providerLabel, selectProvider } from "../agent/provider.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";
20
65
 
21
66
  export const KEYS_URL =
22
67
  "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys";
@@ -30,6 +75,15 @@ export type LoginIO = Readonly<{
30
75
  readSecret(prompt: string): Promise<string>;
31
76
  ask(prompt: string): Promise<string>;
32
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>;
33
87
  }>;
34
88
 
35
89
  export type LoginDeps = Readonly<{
@@ -37,35 +91,179 @@ export type LoginDeps = Readonly<{
37
91
  io: LoginIO;
38
92
  hostname: string;
39
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;
40
101
  }>;
41
102
 
42
- export type LoginMode = "gateway" | "xcb" | "key";
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;
43
110
  export type KeyCheck = "valid" | "rejected" | "unverified";
44
111
 
45
- /** `dawg login [--gateway|--xcb|--key] [--budget <dollars>]` → exit code. */
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. */
46
121
  export async function login(
47
- mode: LoginMode,
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(
48
209
  deps: LoginDeps,
49
- options: { budget?: number; xcb?: XcbPreset } = {},
210
+ id: LoginOption,
211
+ found: Discovery,
212
+ options: LoginOptions,
50
213
  ): Promise<number> {
51
- if (mode === "xcb") return loginXcb(deps, options.xcb);
52
- if (mode === "key") return loginWithPastedKey(deps);
53
- return loginGateway(deps, options);
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;
54
245
  }
55
246
 
247
+ // ---------------------------------------------------------------------------
248
+ // Vercel AI Gateway
249
+
56
250
  async function loginGateway(
57
251
  deps: LoginDeps,
58
252
  options: { budget?: number },
253
+ found?: Discovery,
59
254
  ): Promise<number> {
60
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);
61
259
  const vercel = auth.runner.which("vercel");
62
260
  if (!vercel) {
63
261
  io.print(
64
262
  "The Vercel CLI is not installed. Install it to create a key automatically:",
65
263
  );
66
264
  io.print(` ${VERCEL_INSTALL}`);
67
- io.print("Or paste an existing AI Gateway key instead.");
68
- return loginWithPastedKey(deps);
265
+ io.print("Or paste an AI Gateway key (Enter opens the key page).");
266
+ return loginWithPastedKey(deps, "gateway");
69
267
  }
70
268
  let who = await whoami(deps, vercel);
71
269
  if (!who) {
@@ -75,7 +273,9 @@ async function loginGateway(
75
273
  );
76
274
  return 1;
77
275
  }
78
- io.print("Not logged in to Vercel; starting `vercel login`…");
276
+ io.print(
277
+ "Not logged in to Vercel; starting `vercel login` (opens your browser)…",
278
+ );
79
279
  const result = await auth.runner.run(vercel, ["login"], {
80
280
  inherit: true,
81
281
  timeoutMs: 10 * 60_000,
@@ -122,7 +322,7 @@ async function loginGateway(
122
322
  io.print("You can paste one instead with `dawg login --key`.");
123
323
  return 1;
124
324
  }
125
- return saveAndReport(deps, key, "created");
325
+ return saveAndReport(deps, "gateway", key, "created");
126
326
  }
127
327
 
128
328
  async function whoami(
@@ -154,37 +354,84 @@ async function whoami(
154
354
  }
155
355
  }
156
356
 
157
- async function loginWithPastedKey(deps: LoginDeps): Promise<number> {
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> {
158
403
  const { io } = deps;
404
+ const service = SERVICE_NAMES[kind];
405
+ const page = kind === "gateway" ? KEYS_URL : OPENROUTER_KEYS_URL;
159
406
  if (!io.interactive) {
160
407
  io.print(
161
- "Pasting a key needs a terminal. Or set AI_GATEWAY_API_KEY in your environment.",
408
+ `Pasting a key needs a terminal. Or set ${keyEnvName(kind)} in your environment.`,
162
409
  );
163
410
  return 1;
164
411
  }
165
- io.print(`Create a key at ${KEYS_URL}`);
412
+ io.print(`Create a key at ${page}`);
166
413
  for (let attempt = 0; attempt < 3; attempt += 1) {
167
414
  const entered = (
168
415
  await io.readSecret(
169
- "AI Gateway key (input hidden, Enter to open the key page): ",
416
+ `${service} key (input hidden, Enter to open the key page): `,
170
417
  )
171
418
  ).trim();
172
419
  if (!entered) {
173
- await openUrl(deps, KEYS_URL);
420
+ await openUrl(deps, page, "the key page");
174
421
  continue;
175
422
  }
176
423
  if (!isValidKey(entered)) {
177
424
  io.print(
178
- "That does not look like an AI Gateway key (letters, digits, - _ . only).",
425
+ `That does not look like an ${service} key (letters, digits, - _ . only).`,
179
426
  );
180
427
  continue;
181
428
  }
182
- const check = await checkKey(deps, entered);
429
+ const check = await checkServiceKey(deps, kind, entered);
183
430
  if (check === "rejected") {
184
- io.print("AI Gateway rejected that key. Nothing was saved.");
431
+ io.print(`${service} rejected that key. Nothing was saved.`);
185
432
  continue;
186
433
  }
187
- return saveAndReport(deps, entered, "pasted", check);
434
+ return saveAndReport(deps, kind, entered, "pasted", check);
188
435
  }
189
436
  io.print("No key saved.");
190
437
  return 1;
@@ -192,29 +439,53 @@ async function loginWithPastedKey(deps: LoginDeps): Promise<number> {
192
439
 
193
440
  async function saveAndReport(
194
441
  deps: LoginDeps,
442
+ kind: KeyKind,
195
443
  key: string,
196
- how: "created" | "pasted",
444
+ how: "created" | "pasted" | "authorized",
197
445
  knownCheck?: KeyCheck,
198
446
  ): Promise<number> {
199
447
  const { auth, io } = deps;
200
- const where = await storeGatewayKey(auth, key);
201
- await writeConfig(auth, { provider: "gateway" });
448
+ const service = SERVICE_NAMES[kind];
449
+ const where = await storeKey(auth, kind, key);
202
450
  io.print(
203
451
  `Saved ${how} key ${maskKey(key)} to ${where === "keychain" ? "the macOS Keychain" : "~/.config/dawg/credentials.json"}.`,
204
452
  );
205
- const check = knownCheck ?? (await checkKey(deps, key));
453
+ const check = knownCheck ?? (await checkServiceKey(deps, kind, key));
206
454
  io.print(
207
455
  check === "valid"
208
- ? "✓ AI Gateway accepted the key. Run `dawg` and type a request."
456
+ ? `✓ ${service} accepted the key.`
209
457
  : check === "rejected"
210
- ? "✗ AI Gateway rejected the key (new keys can take a moment; check with `dawg auth status`)."
211
- : "? Could not reach AI Gateway to verify the key; it is saved and will be used when online.",
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.`,
212
460
  );
213
- if (auth.env.AI_GATEWAY_API_KEY)
461
+ if (check === "rejected") return 1;
462
+ await selectService(deps, kind);
463
+ if (auth.env[keyEnvName(kind)])
214
464
  io.print(
215
- "Note: AI_GATEWAY_API_KEY is set in your environment and takes precedence.",
465
+ `Note: ${keyEnvName(kind)} is set in your environment and takes precedence.`,
216
466
  );
217
- return check === "rejected" ? 1 : 0;
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 } : {};
218
489
  }
219
490
 
220
491
  /** A cheap authenticated request (`GET /v1/credits`). */
@@ -241,30 +512,294 @@ export async function checkKey(
241
512
  }
242
513
  }
243
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
+
244
566
  /** A pre-chosen xcb account and model (from the TUI picker). */
245
567
  export type XcbPreset = Readonly<{ account: string; model: string }>;
246
568
  export type XcbChoice = Readonly<{ account: XcbAccount; model: XcbModel }>;
247
569
 
248
570
  /**
249
- * Every usable (account, model) pair xcb reports, without printing. Accounts
250
- * with a pending automatic admission count as usable.
571
+ * Every usable (account, model) pair xcb reports, without printing. An
572
+ * account is usable iff `available`; models with no admission are skipped.
251
573
  */
252
574
  export async function listXcbChoices(
253
575
  auth: AuthEnv,
576
+ family?: string,
254
577
  ): Promise<XcbChoice[] | undefined> {
255
578
  const bin = resolveXcbBin(auth.env, auth.runner);
256
579
  if (!bin) return undefined;
257
580
  try {
258
- return (await readCapabilities(bin, auth.runner)).accounts
259
- .filter((account) => account.available)
260
- .flatMap((account) =>
261
- account.models.map((model) => ({ account, model })),
262
- );
581
+ return usableChoices(
582
+ (await readCapabilities(bin, auth.runner)).accounts,
583
+ family,
584
+ );
263
585
  } catch {
264
586
  return undefined;
265
587
  }
266
588
  }
267
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). */
268
803
  async function loginXcb(deps: LoginDeps, preset?: XcbPreset): Promise<number> {
269
804
  const { auth, io } = deps;
270
805
  const bin = resolveXcbBin(auth.env, auth.runner);
@@ -286,31 +821,19 @@ async function loginXcb(deps: LoginDeps, preset?: XcbPreset): Promise<number> {
286
821
  );
287
822
  return 1;
288
823
  }
289
- const choices = accounts
290
- .filter((account) => account.available)
291
- .flatMap((account) => account.models.map((model) => ({ account, model })));
824
+ const choices = usableChoices(accounts);
292
825
  if (choices.length === 0) {
293
826
  io.print("No xcb account is available for applications yet.");
294
- io.print(
295
- "An account must pass xcb's application qualification before apps like dawg can use it:",
296
- );
297
- io.print(` ${XCB_DOC_URL}#application-checks-and-expiry`);
298
- const connected = accounts
299
- .filter((account) => account.connected)
300
- .slice(0, 8);
301
- if (connected.length > 0) {
827
+ for (const account of accounts.slice(0, 12))
302
828
  io.print(
303
- "Inspect what qualification would bind for an account (read-only):",
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`.",
304
834
  );
305
- for (const account of connected)
306
- io.print(
307
- ` xcb --json qualify-application --inspect --account ${account.id} --model <model-key> # ${account.label} · ${account.provider} · ${account.reason ?? "ready"}`,
308
- );
309
- io.print("List model keys with `xcb models`.");
310
- } else io.print("No connected accounts found; connect one with xcb first.");
311
835
  return 1;
312
836
  }
313
- let picked = choices[0]!;
314
837
  if (preset) {
315
838
  const match = choices.find(
316
839
  (choice) =>
@@ -323,100 +846,330 @@ async function loginXcb(deps: LoginDeps, preset?: XcbPreset): Promise<number> {
323
846
  );
324
847
  return 1;
325
848
  }
326
- picked = match;
327
- } else if (choices.length > 1) {
328
- choices.forEach((choice, index) =>
329
- io.print(
330
- ` ${index + 1}) ${choice.account.label} · ${choice.account.provider} · ${choice.model.key}`,
331
- ),
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,
332
918
  );
333
- if (io.interactive) {
334
- const answer = (await io.ask(`Choose 1-${choices.length} [1]: `)).trim();
335
- const index = answer ? Number.parseInt(answer, 10) - 1 : 0;
336
- if (!Number.isInteger(index) || index < 0 || index >= choices.length) {
337
- io.print("No account selected. Nothing was saved.");
338
- return 1;
339
- }
340
- picked = choices[index]!;
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;
341
981
  }
982
+ io.print(`model · ${await saveModelChoice(auth, selection, value)}`);
983
+ return 0;
342
984
  }
343
- await writeConfig(auth, {
344
- provider: "xcb",
345
- xcb: { account: picked.account.id, model: picked.model.key },
346
- });
347
- io.print(
348
- `✓ Using ${picked.account.label} (${picked.account.provider}) · ${picked.model.key} through xcb.`,
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
+ },
349
1005
  );
350
- if (picked.account.admission === "pending")
351
- io.print(
352
- "xcb admits this account on its first request, so the first turn takes longer.",
353
- );
1006
+ if (index === undefined) {
1007
+ io.print(`model · ${providerLabel(selection)} (unchanged)`);
1008
+ return io.interactive ? 0 : 0;
1009
+ }
354
1010
  io.print(
355
- "Saved to ~/.config/dawg/config.json (no secrets). Run `dawg` and type a request.",
1011
+ `model · ${await saveModelChoice(auth, selection, rows[index]!.value)}`,
356
1012
  );
357
1013
  return 0;
358
1014
  }
359
1015
 
360
- /** `dawg logout`: remove stored keys and the saved provider choice. */
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
+ */
361
1030
  export async function logout(
362
1031
  deps: Pick<LoginDeps, "auth" | "io">,
1032
+ target?: LogoutTarget,
363
1033
  ): Promise<number> {
364
- const removed = await clearGatewayKey(deps.auth);
365
- await writeConfig(deps.auth, { provider: undefined, xcb: undefined });
366
- deps.io.print(
367
- removed.length > 0
368
- ? `Removed the stored AI Gateway key (${removed.join(", ")}).`
369
- : "No stored AI Gateway key.",
370
- );
371
- deps.io.print("Cleared the saved provider choice.");
372
- if (deps.auth.env.AI_GATEWAY_API_KEY)
373
- deps.io.print("AI_GATEWAY_API_KEY is still set in your environment.");
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);
374
1073
  return 0;
375
1074
  }
376
1075
 
377
- /** `dawg auth status` / `/auth`. Never prints a key, only its mask. */
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
+ */
378
1081
  export async function authStatus(
379
- deps: Pick<LoginDeps, "auth" | "fetcher">,
380
- options: { verify?: boolean; gatewayModel?: "opus-5.5" | "sol-6.1" } = {},
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
+ } = {},
381
1091
  ): Promise<string[]> {
382
1092
  const { auth } = deps;
383
- const lines: string[] = [];
384
- const selection = await selectProvider(auth);
385
- const config = await readConfig(auth);
386
- lines.push(
387
- `provider: ${providerLabel(selection, options.gatewayModel ?? "opus-5.5")} (${selection.choice}${
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)}${
388
1107
  auth.env.DAWG_PROVIDER
389
- ? " from DAWG_PROVIDER"
1108
+ ? " (from DAWG_PROVIDER)"
390
1109
  : config.provider
391
- ? " saved"
392
- : ""
393
- })${selection.kind === "offline" ? ` · ${selection.reason}` : ""}`,
394
- );
395
- const key = await resolveGatewayKey(auth);
396
- if (key) {
397
- const check = options.verify ? ` · ${await checkKey(deps, key.key)}` : "";
398
- lines.push(`ai gateway key: ${maskKey(key.key)} (${key.source})${check}`);
399
- } else lines.push("ai gateway key: none");
400
- const bin = resolveXcbBin(auth.env, auth.runner);
401
- if (!bin) lines.push("xcb: not installed");
402
- else if (selection.kind === "xcb")
403
- lines.push(`xcb: ${selection.accountLabel} · ${selection.model}`);
404
- else if (config.xcb)
405
- lines.push(`xcb: saved ${config.xcb.account} · ${config.xcb.model}`);
406
- else lines.push("xcb: installed, no account chosen (`dawg login --xcb`)");
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));
407
1131
  return lines;
408
1132
  }
409
1133
 
410
- async function openUrl(deps: LoginDeps, url: string): Promise<void> {
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> {
411
1167
  const opener = deps.auth.platform === "darwin" ? "open" : "xdg-open";
412
- if (!deps.auth.runner.which(opener)) {
413
- deps.io.print(`Open ${url}`);
414
- return;
415
- }
416
- await deps.auth.runner
1168
+ if (!deps.auth.runner.which(opener)) return;
1169
+ const result = await deps.auth.runner
417
1170
  .run(opener, [url], { timeoutMs: 10_000 })
418
1171
  .catch(() => undefined);
419
- deps.io.print("Opened the key page in your browser.");
1172
+ if (result?.code === 0) deps.io.print(`Opened ${what} in your browser.`);
420
1173
  }
421
1174
 
422
1175
  export function safeHostname(hostname: string): string {
@@ -430,5 +1183,9 @@ export function safeHostname(hostname: string): string {
430
1183
  }
431
1184
 
432
1185
  function redactKeys(text: string): string {
433
- return text.replace(/\bvck_[A-Za-z0-9._-]+/g, "vck_[redacted]");
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]");
434
1189
  }
1190
+
1191
+ export type { ProviderChoice };