@hraness/dawg 0.0.0-stage → 0.2.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 (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
@@ -0,0 +1,434 @@
1
+ import {
2
+ clearGatewayKey,
3
+ isValidKey,
4
+ maskKey,
5
+ readConfig,
6
+ resolveGatewayKey,
7
+ storeGatewayKey,
8
+ writeConfig,
9
+ type AuthEnv,
10
+ } from "./credentials.ts";
11
+ import {
12
+ readCapabilities,
13
+ resolveXcbBin,
14
+ XCB_DOC_URL,
15
+ XCB_INSTALL,
16
+ type XcbAccount,
17
+ type XcbModel,
18
+ } from "../agent/xcb.ts";
19
+ import { providerLabel, selectProvider } from "../agent/provider.ts";
20
+
21
+ export const KEYS_URL =
22
+ "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys";
23
+ export const VERCEL_INSTALL = "bun add -g vercel # or: npm i -g vercel";
24
+ const DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1";
25
+
26
+ /** Terminal interaction, injectable so flows are testable without a TTY. */
27
+ export type LoginIO = Readonly<{
28
+ print(line: string): void;
29
+ /** Read a line without echo. */
30
+ readSecret(prompt: string): Promise<string>;
31
+ ask(prompt: string): Promise<string>;
32
+ interactive: boolean;
33
+ }>;
34
+
35
+ export type LoginDeps = Readonly<{
36
+ auth: AuthEnv;
37
+ io: LoginIO;
38
+ hostname: string;
39
+ fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
40
+ }>;
41
+
42
+ export type LoginMode = "gateway" | "xcb" | "key";
43
+ export type KeyCheck = "valid" | "rejected" | "unverified";
44
+
45
+ /** `dawg login [--gateway|--xcb|--key] [--budget <dollars>]` → exit code. */
46
+ export async function login(
47
+ mode: LoginMode,
48
+ deps: LoginDeps,
49
+ options: { budget?: number; xcb?: XcbPreset } = {},
50
+ ): Promise<number> {
51
+ if (mode === "xcb") return loginXcb(deps, options.xcb);
52
+ if (mode === "key") return loginWithPastedKey(deps);
53
+ return loginGateway(deps, options);
54
+ }
55
+
56
+ async function loginGateway(
57
+ deps: LoginDeps,
58
+ options: { budget?: number },
59
+ ): Promise<number> {
60
+ const { auth, io } = deps;
61
+ const vercel = auth.runner.which("vercel");
62
+ if (!vercel) {
63
+ io.print(
64
+ "The Vercel CLI is not installed. Install it to create a key automatically:",
65
+ );
66
+ io.print(` ${VERCEL_INSTALL}`);
67
+ io.print("Or paste an existing AI Gateway key instead.");
68
+ return loginWithPastedKey(deps);
69
+ }
70
+ let who = await whoami(deps, vercel);
71
+ if (!who) {
72
+ if (!io.interactive) {
73
+ io.print(
74
+ "Not logged in to Vercel. Run `vercel login` first, or re-run `dawg login` in a terminal.",
75
+ );
76
+ return 1;
77
+ }
78
+ io.print("Not logged in to Vercel; starting `vercel login`…");
79
+ const result = await auth.runner.run(vercel, ["login"], {
80
+ inherit: true,
81
+ timeoutMs: 10 * 60_000,
82
+ });
83
+ if (result.code !== 0) {
84
+ io.print("`vercel login` did not finish. Nothing was saved.");
85
+ return 1;
86
+ }
87
+ who = await whoami(deps, vercel);
88
+ if (!who) {
89
+ io.print("Still not logged in to Vercel. Nothing was saved.");
90
+ return 1;
91
+ }
92
+ }
93
+ io.print(`Vercel: ${who}`);
94
+ const name = `dawg-${safeHostname(deps.hostname)}`;
95
+ const args = [
96
+ "ai-gateway",
97
+ "api-keys",
98
+ "create",
99
+ "--name",
100
+ name,
101
+ "--non-interactive",
102
+ ];
103
+ if (options.budget !== undefined)
104
+ args.push("--limit", String(options.budget));
105
+ io.print(
106
+ `Creating AI Gateway key "${name}"${options.budget !== undefined ? ` with a $${options.budget} limit` : ""}…`,
107
+ );
108
+ const created = await auth.runner.run(vercel, args, {
109
+ timeoutMs: 60_000,
110
+ maxOutputBytes: 64 * 1024,
111
+ });
112
+ // The CLI prints only the key on stdout; status lines go to stderr.
113
+ const key = created.stdout
114
+ .split(/\r?\n/)
115
+ .map((line) => line.trim())
116
+ .find((line) => isValidKey(line));
117
+ if (created.code !== 0 || !key) {
118
+ const detail = created.stderr.replace(/\s+/g, " ").trim().slice(0, 200);
119
+ io.print(
120
+ `Could not create a key${detail ? `: ${redactKeys(detail)}` : ` (exit ${created.code})`}.`,
121
+ );
122
+ io.print("You can paste one instead with `dawg login --key`.");
123
+ return 1;
124
+ }
125
+ return saveAndReport(deps, key, "created");
126
+ }
127
+
128
+ async function whoami(
129
+ deps: LoginDeps,
130
+ vercel: string,
131
+ ): Promise<string | undefined> {
132
+ const result = await deps.auth.runner
133
+ .run(vercel, ["whoami", "--format", "json", "--non-interactive"], {
134
+ timeoutMs: 20_000,
135
+ maxOutputBytes: 64 * 1024,
136
+ })
137
+ .catch(() => undefined);
138
+ if (!result || result.code !== 0) return undefined;
139
+ try {
140
+ const parsed: unknown = JSON.parse(result.stdout);
141
+ if (typeof parsed !== "object" || parsed === null) return undefined;
142
+ const record = parsed as Record<string, unknown>;
143
+ const username =
144
+ typeof record.username === "string" ? record.username : undefined;
145
+ if (!username || !/^[\w.@+-]{1,64}$/.test(username)) return undefined;
146
+ const team = record.team as Record<string, unknown> | undefined;
147
+ const slug =
148
+ team && typeof team.slug === "string" && /^[\w.-]{1,64}$/.test(team.slug)
149
+ ? team.slug
150
+ : undefined;
151
+ return slug ? `${username} (team ${slug})` : username;
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ async function loginWithPastedKey(deps: LoginDeps): Promise<number> {
158
+ const { io } = deps;
159
+ if (!io.interactive) {
160
+ io.print(
161
+ "Pasting a key needs a terminal. Or set AI_GATEWAY_API_KEY in your environment.",
162
+ );
163
+ return 1;
164
+ }
165
+ io.print(`Create a key at ${KEYS_URL}`);
166
+ for (let attempt = 0; attempt < 3; attempt += 1) {
167
+ const entered = (
168
+ await io.readSecret(
169
+ "AI Gateway key (input hidden, Enter to open the key page): ",
170
+ )
171
+ ).trim();
172
+ if (!entered) {
173
+ await openUrl(deps, KEYS_URL);
174
+ continue;
175
+ }
176
+ if (!isValidKey(entered)) {
177
+ io.print(
178
+ "That does not look like an AI Gateway key (letters, digits, - _ . only).",
179
+ );
180
+ continue;
181
+ }
182
+ const check = await checkKey(deps, entered);
183
+ if (check === "rejected") {
184
+ io.print("AI Gateway rejected that key. Nothing was saved.");
185
+ continue;
186
+ }
187
+ return saveAndReport(deps, entered, "pasted", check);
188
+ }
189
+ io.print("No key saved.");
190
+ return 1;
191
+ }
192
+
193
+ async function saveAndReport(
194
+ deps: LoginDeps,
195
+ key: string,
196
+ how: "created" | "pasted",
197
+ knownCheck?: KeyCheck,
198
+ ): Promise<number> {
199
+ const { auth, io } = deps;
200
+ const where = await storeGatewayKey(auth, key);
201
+ await writeConfig(auth, { provider: "gateway" });
202
+ io.print(
203
+ `Saved ${how} key ${maskKey(key)} to ${where === "keychain" ? "the macOS Keychain" : "~/.config/dawg/credentials.json"}.`,
204
+ );
205
+ const check = knownCheck ?? (await checkKey(deps, key));
206
+ io.print(
207
+ check === "valid"
208
+ ? "✓ AI Gateway accepted the key. Run `dawg` and type a request."
209
+ : 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.",
212
+ );
213
+ if (auth.env.AI_GATEWAY_API_KEY)
214
+ io.print(
215
+ "Note: AI_GATEWAY_API_KEY is set in your environment and takes precedence.",
216
+ );
217
+ return check === "rejected" ? 1 : 0;
218
+ }
219
+
220
+ /** A cheap authenticated request (`GET /v1/credits`). */
221
+ export async function checkKey(
222
+ deps: Pick<LoginDeps, "auth" | "fetcher">,
223
+ key: string,
224
+ ): Promise<KeyCheck> {
225
+ const fetcher = deps.fetcher ?? fetch;
226
+ const base = (deps.auth.env.AI_GATEWAY_BASE_URL || DEFAULT_BASE_URL).replace(
227
+ /\/$/,
228
+ "",
229
+ );
230
+ try {
231
+ const response = await fetcher(`${base}/credits`, {
232
+ headers: { authorization: `Bearer ${key}` },
233
+ signal: AbortSignal.timeout(8_000),
234
+ });
235
+ await response.body?.cancel().catch(() => undefined);
236
+ if (response.ok) return "valid";
237
+ if (response.status === 401 || response.status === 403) return "rejected";
238
+ return "unverified";
239
+ } catch {
240
+ return "unverified";
241
+ }
242
+ }
243
+
244
+ /** A pre-chosen xcb account and model (from the TUI picker). */
245
+ export type XcbPreset = Readonly<{ account: string; model: string }>;
246
+ export type XcbChoice = Readonly<{ account: XcbAccount; model: XcbModel }>;
247
+
248
+ /**
249
+ * Every usable (account, model) pair xcb reports, without printing. Accounts
250
+ * with a pending automatic admission count as usable.
251
+ */
252
+ export async function listXcbChoices(
253
+ auth: AuthEnv,
254
+ ): Promise<XcbChoice[] | undefined> {
255
+ const bin = resolveXcbBin(auth.env, auth.runner);
256
+ if (!bin) return undefined;
257
+ 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
+ );
263
+ } catch {
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ async function loginXcb(deps: LoginDeps, preset?: XcbPreset): Promise<number> {
269
+ const { auth, io } = deps;
270
+ const bin = resolveXcbBin(auth.env, auth.runner);
271
+ if (!bin) {
272
+ io.print(
273
+ "xcb is not installed. It routes dawg's requests to your Claude, Codex or Devin subscription.",
274
+ );
275
+ io.print(` Install: ${XCB_INSTALL}`);
276
+ io.print(` Docs: ${XCB_DOC_URL}`);
277
+ io.print("Then run `dawg login --xcb` again (or set XCB_BIN to its path).");
278
+ return 1;
279
+ }
280
+ let accounts: readonly XcbAccount[];
281
+ try {
282
+ accounts = (await readCapabilities(bin, auth.runner)).accounts;
283
+ } catch (error) {
284
+ io.print(
285
+ `Could not read xcb capabilities: ${error instanceof Error ? error.message : String(error)}`,
286
+ );
287
+ return 1;
288
+ }
289
+ const choices = accounts
290
+ .filter((account) => account.available)
291
+ .flatMap((account) => account.models.map((model) => ({ account, model })));
292
+ if (choices.length === 0) {
293
+ 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) {
302
+ io.print(
303
+ "Inspect what qualification would bind for an account (read-only):",
304
+ );
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
+ return 1;
312
+ }
313
+ let picked = choices[0]!;
314
+ if (preset) {
315
+ const match = choices.find(
316
+ (choice) =>
317
+ choice.account.id === preset.account &&
318
+ choice.model.key === preset.model,
319
+ );
320
+ if (!match) {
321
+ io.print(
322
+ `xcb account ${preset.account.slice(0, 24)} · ${preset.model.slice(0, 60)} is no longer available. Nothing was saved.`,
323
+ );
324
+ return 1;
325
+ }
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
+ ),
332
+ );
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]!;
341
+ }
342
+ }
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.`,
349
+ );
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
+ );
354
+ io.print(
355
+ "Saved to ~/.config/dawg/config.json (no secrets). Run `dawg` and type a request.",
356
+ );
357
+ return 0;
358
+ }
359
+
360
+ /** `dawg logout`: remove stored keys and the saved provider choice. */
361
+ export async function logout(
362
+ deps: Pick<LoginDeps, "auth" | "io">,
363
+ ): 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.");
374
+ return 0;
375
+ }
376
+
377
+ /** `dawg auth status` / `/auth`. Never prints a key, only its mask. */
378
+ export async function authStatus(
379
+ deps: Pick<LoginDeps, "auth" | "fetcher">,
380
+ options: { verify?: boolean; gatewayModel?: "opus-5.5" | "sol-6.1" } = {},
381
+ ): Promise<string[]> {
382
+ 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}${
388
+ auth.env.DAWG_PROVIDER
389
+ ? " from DAWG_PROVIDER"
390
+ : 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`)");
407
+ return lines;
408
+ }
409
+
410
+ async function openUrl(deps: LoginDeps, url: string): Promise<void> {
411
+ 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
417
+ .run(opener, [url], { timeoutMs: 10_000 })
418
+ .catch(() => undefined);
419
+ deps.io.print("Opened the key page in your browser.");
420
+ }
421
+
422
+ export function safeHostname(hostname: string): string {
423
+ const cleaned = hostname
424
+ .toLowerCase()
425
+ .replace(/\.local$/, "")
426
+ .replace(/[^a-z0-9-]+/g, "-")
427
+ .replace(/^-+|-+$/g, "")
428
+ .slice(0, 40);
429
+ return cleaned || "host";
430
+ }
431
+
432
+ function redactKeys(text: string): string {
433
+ return text.replace(/\bvck_[A-Za-z0-9._-]+/g, "vck_[redacted]");
434
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The one place dawg starts helper CLIs (`vercel`, `security`, `xcb`).
3
+ * Everything above it takes a `CommandRunner`, so tests script the
4
+ * subprocesses instead of touching real accounts or the keychain.
5
+ */
6
+ export type RunOptions = Readonly<{
7
+ /** Written to the child's stdin, then stdin is closed. */
8
+ stdin?: string;
9
+ /** Hand the terminal to the child (for `vercel login`). Output is not captured. */
10
+ inherit?: boolean;
11
+ timeoutMs?: number;
12
+ /** Aborting sends SIGTERM and still waits for the child to exit. */
13
+ signal?: AbortSignal;
14
+ /** Bound on captured stdout/stderr; excess is dropped. */
15
+ maxOutputBytes?: number;
16
+ env?: Readonly<Record<string, string | undefined>>;
17
+ }>;
18
+
19
+ export type RunResult = Readonly<{
20
+ code: number;
21
+ stdout: string;
22
+ stderr: string;
23
+ /** True when the child was stopped by `signal` or `timeoutMs`. */
24
+ killed: boolean;
25
+ }>;
26
+
27
+ export type CommandRunner = Readonly<{
28
+ run(
29
+ command: string,
30
+ args: readonly string[],
31
+ options?: RunOptions,
32
+ ): Promise<RunResult>;
33
+ /** Absolute path of an executable on PATH, or undefined. */
34
+ which(command: string): string | undefined;
35
+ }>;
36
+
37
+ const DEFAULT_MAX_OUTPUT = 1024 * 1024;
38
+ const KILL_GRACE_MS = 15_000;
39
+
40
+ export const systemRunner: CommandRunner = {
41
+ which: (command) => Bun.which(command) ?? undefined,
42
+ async run(command, args, options = {}) {
43
+ const maxBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
44
+ const env = options.env ? { ...process.env, ...options.env } : process.env;
45
+ if (options.inherit) {
46
+ const child = Bun.spawn([command, ...args], {
47
+ stdin: "inherit",
48
+ stdout: "inherit",
49
+ stderr: "inherit",
50
+ env,
51
+ });
52
+ const stop = watchKill(child, options);
53
+ try {
54
+ const code = await child.exited;
55
+ return { code, stdout: "", stderr: "", killed: stop.killed() };
56
+ } finally {
57
+ stop.dispose();
58
+ }
59
+ }
60
+ const child = Bun.spawn([command, ...args], {
61
+ stdin: options.stdin === undefined ? "ignore" : "pipe",
62
+ stdout: "pipe",
63
+ stderr: "pipe",
64
+ env,
65
+ });
66
+ const stop = watchKill(child, options);
67
+ try {
68
+ if (options.stdin !== undefined && child.stdin) {
69
+ try {
70
+ child.stdin.write(options.stdin);
71
+ await child.stdin.end();
72
+ } catch {
73
+ // The child may exit before reading its input; its exit code reports why.
74
+ }
75
+ }
76
+ const [stdout, stderr, code] = await Promise.all([
77
+ readBounded(child.stdout, maxBytes),
78
+ readBounded(child.stderr, maxBytes),
79
+ child.exited,
80
+ ]);
81
+ return { code, stdout, stderr, killed: stop.killed() };
82
+ } finally {
83
+ stop.dispose();
84
+ }
85
+ },
86
+ };
87
+
88
+ function watchKill(
89
+ child: { kill(signal?: number | NodeJS.Signals): void },
90
+ options: RunOptions,
91
+ ): { killed(): boolean; dispose(): void } {
92
+ let killed = false;
93
+ let escalate: ReturnType<typeof setTimeout> | undefined;
94
+ const kill = () => {
95
+ if (killed) return;
96
+ killed = true;
97
+ try {
98
+ child.kill("SIGTERM");
99
+ } catch {
100
+ // Already exited.
101
+ }
102
+ // Callers still await exit; a child that ignores SIGTERM gets SIGKILL.
103
+ escalate = setTimeout(() => {
104
+ try {
105
+ child.kill("SIGKILL");
106
+ } catch {
107
+ // Already exited.
108
+ }
109
+ }, KILL_GRACE_MS);
110
+ };
111
+ const timer =
112
+ options.timeoutMs !== undefined
113
+ ? setTimeout(kill, options.timeoutMs)
114
+ : undefined;
115
+ if (options.signal?.aborted) kill();
116
+ options.signal?.addEventListener("abort", kill, { once: true });
117
+ return {
118
+ killed: () => killed,
119
+ dispose() {
120
+ if (timer) clearTimeout(timer);
121
+ if (escalate) clearTimeout(escalate);
122
+ options.signal?.removeEventListener("abort", kill);
123
+ },
124
+ };
125
+ }
126
+
127
+ async function readBounded(
128
+ stream: ReadableStream<Uint8Array> | undefined | null,
129
+ maxBytes: number,
130
+ ): Promise<string> {
131
+ if (!stream) return "";
132
+ const decoder = new TextDecoder();
133
+ let text = "";
134
+ let bytes = 0;
135
+ for await (const chunk of stream) {
136
+ if (bytes >= maxBytes) continue; // Keep draining so the child never blocks.
137
+ const room = maxBytes - bytes;
138
+ const part = chunk.byteLength > room ? chunk.subarray(0, room) : chunk;
139
+ bytes += part.byteLength;
140
+ text += decoder.decode(part, { stream: true });
141
+ }
142
+ return text + decoder.decode();
143
+ }
144
+
145
+ /** A scripted runner for tests: each call is matched in order by a predicate. */
146
+ export type ScriptedCall = Readonly<{
147
+ match: (command: string, args: readonly string[]) => boolean;
148
+ result?: Partial<RunResult>;
149
+ respond?: (
150
+ command: string,
151
+ args: readonly string[],
152
+ options: RunOptions,
153
+ ) => Promise<Partial<RunResult>> | Partial<RunResult>;
154
+ }>;
155
+
156
+ export function scriptedRunner(
157
+ script: readonly ScriptedCall[],
158
+ installed: readonly string[] = [],
159
+ ): CommandRunner & {
160
+ calls: { command: string; args: readonly string[]; options: RunOptions }[];
161
+ } {
162
+ const calls: {
163
+ command: string;
164
+ args: readonly string[];
165
+ options: RunOptions;
166
+ }[] = [];
167
+ const remaining = [...script];
168
+ return {
169
+ calls,
170
+ which: (command) =>
171
+ installed.includes(command) ? `/usr/local/bin/${command}` : undefined,
172
+ async run(command, args, options = {}) {
173
+ calls.push({ command, args, options });
174
+ const index = remaining.findIndex((step) => step.match(command, args));
175
+ if (index < 0)
176
+ throw new Error(`unexpected command: ${command} ${args.join(" ")}`);
177
+ const [step] = remaining.splice(index, 1);
178
+ const partial = step!.respond
179
+ ? await step!.respond(command, args, options)
180
+ : (step!.result ?? {});
181
+ return { code: 0, stdout: "", stderr: "", killed: false, ...partial };
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,90 @@
1
+ import { hostname } from "node:os";
2
+ import { defaultAuthEnv } from "./credentials.ts";
3
+ import {
4
+ authStatus,
5
+ listXcbChoices,
6
+ login,
7
+ logout,
8
+ type LoginIO,
9
+ } from "./login.ts";
10
+ import { systemRunner, type CommandRunner } from "./runner.ts";
11
+ import type { GatewayModel } from "../agent/gateway.ts";
12
+ import { audioStatusLine } from "../audio/engine.ts";
13
+
14
+ /**
15
+ * `/login [--xcb]`, `/logout` and `/auth` inside the TUI. The terminal is in
16
+ * raw mode, so there is no hidden input or `vercel login` handoff here: those
17
+ * flows tell the user to run `dawg login` in a shell instead.
18
+ */
19
+ export async function tuiAuthCommand(
20
+ command: string,
21
+ gatewayModel: GatewayModel,
22
+ runner: CommandRunner = systemRunner,
23
+ ): Promise<string[]> {
24
+ const lines: string[] = [];
25
+ const io: LoginIO = {
26
+ interactive: false,
27
+ print: (line) => void lines.push(line),
28
+ ask: async () => "",
29
+ readSecret: async () => "",
30
+ };
31
+ const deps = { auth: defaultAuthEnv(runner), io, hostname: hostname() };
32
+ const [name, ...rest] = command.trim().split(/\s+/);
33
+ try {
34
+ if (/^\/logout$/i.test(name ?? "")) await logout(deps);
35
+ else if (/^\/auth$/i.test(name ?? ""))
36
+ lines.push(
37
+ ...(await authStatus(deps, {
38
+ verify: rest.includes("--check"),
39
+ gatewayModel,
40
+ })),
41
+ audioStatusLine(),
42
+ );
43
+ else if (rest.includes("--xcb")) {
44
+ const account = flagValue(rest, "--account");
45
+ const model = flagValue(rest, "--model");
46
+ await login(
47
+ "xcb",
48
+ deps,
49
+ account && model ? { xcb: { account, model } } : {},
50
+ );
51
+ } else if (rest.includes("--key"))
52
+ lines.push(
53
+ "Paste a key from a shell: `dawg login --key` (input is hidden there).",
54
+ );
55
+ else await login("gateway", deps);
56
+ } catch (error) {
57
+ lines.push(
58
+ `auth failed: ${error instanceof Error ? error.message : String(error)}`,
59
+ );
60
+ }
61
+ return lines.map((line) => line.slice(0, 240));
62
+ }
63
+
64
+ function flagValue(args: readonly string[], flag: string): string | undefined {
65
+ const index = args.indexOf(flag);
66
+ const value = index >= 0 ? args[index + 1] : undefined;
67
+ return value && !value.startsWith("--") ? value : undefined;
68
+ }
69
+
70
+ export type XcbPickerItem = Readonly<{ label: string; value: string }>;
71
+
72
+ /**
73
+ * Picker rows for `/login --xcb`: one per usable account and model, valued as
74
+ * the follow-up command that saves it. Undefined when xcb is missing or
75
+ * unreadable (the plain login path then prints the guidance).
76
+ */
77
+ export async function xcbPickerItems(
78
+ runner: CommandRunner = systemRunner,
79
+ ): Promise<XcbPickerItem[] | undefined> {
80
+ const choices = await listXcbChoices(defaultAuthEnv(runner));
81
+ return choices?.map(({ account, model }) => ({
82
+ label: [
83
+ account.label,
84
+ account.provider,
85
+ model.label === model.key ? model.key : `${model.label} (${model.key})`,
86
+ ...(account.admission === "pending" ? ["admits on first use"] : []),
87
+ ].join(" · "),
88
+ value: `/login --xcb --account ${account.id} --model ${model.key}`,
89
+ }));
90
+ }