@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/audio/wav.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  SCORE_LIMITS,
3
+ isSamplerInstrument,
3
4
  isTrackAudible,
4
5
  type AutomationPoint,
5
6
  type Note,
@@ -7,6 +8,12 @@ import {
7
8
  type TrackScore,
8
9
  } from "../../core/score.ts";
9
10
  import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
11
+ import {
12
+ planSamplerVoices,
13
+ renderSamplerVoices,
14
+ samplerTailSeconds,
15
+ } from "./sampler.ts";
16
+ import { EMPTY_SAMPLE_BANK, sampleKey, type SampleBank } from "./samples.ts";
10
17
 
11
18
  export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
12
19
 
@@ -18,6 +25,11 @@ export type RenderOptions = WavOptions &
18
25
  * what the streaming engine plays; exports keep the default one-shot.
19
26
  */
20
27
  loop?: boolean;
28
+ /**
29
+ * Decoded sampler voices (`SampleLibrary.load`). Without it sampler
30
+ * tracks render silent; scores without samplers are unaffected.
31
+ */
32
+ samples?: SampleBank;
21
33
  }>;
22
34
 
23
35
  /** Interleaved stereo 16-bit PCM plus its frame count. */
@@ -83,7 +95,7 @@ export function clampSampleRate(sampleRate?: number): number {
83
95
  /** Render the bounded score to a stereo 16-bit PCM WAV. */
84
96
  export function renderScoreWav(
85
97
  score: TrackScore,
86
- options: WavOptions = {},
98
+ options: WavOptions & Pick<RenderOptions, "samples"> = {},
87
99
  ): Uint8Array {
88
100
  const audio = renderScorePcm(score, options);
89
101
  return encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
@@ -98,92 +110,294 @@ export function renderScorePcm(
98
110
  score: TrackScore,
99
111
  options: RenderOptions = {},
100
112
  ): RenderedAudio {
101
- const sampleRate = clampSampleRate(options.sampleRate);
102
- const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
103
- const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
104
- const reverbTail = Math.max(
105
- 0,
106
- ...score.tracks.map((track) =>
107
- track.reverb && track.reverb.mix > 0 ? 1 + 3 * track.reverb.size : 0,
108
- ),
109
- );
110
- let frames: number;
111
- let samples: number;
112
- if (options.loop) {
113
- frames = Math.max(
114
- 1,
115
- Math.min(
116
- Math.round(loopFrames(score, sampleRate)),
117
- Math.ceil(maxSeconds * sampleRate),
113
+ return new StemRenderer({ maxCacheBytes: 0 }).render(score, options);
114
+ }
115
+
116
+ /** Default byte budget for cached per-track stems. */
117
+ export const DEFAULT_STEM_CACHE_BYTES = 256 * 1024 * 1024;
118
+
119
+ type Stem = {
120
+ key: string;
121
+ left: Float64Array;
122
+ right: Float64Array;
123
+ bytes: number;
124
+ used: number;
125
+ };
126
+
127
+ /**
128
+ * The renderer behind `renderScorePcm`, with a per-track stem cache. Each
129
+ * track's stereo stem (after its own effects) is keyed by everything that
130
+ * shapes it: the track settings, its notes, tempo, tick resolution, sample
131
+ * rate and buffer length. Edits re-render only the tracks whose key changed
132
+ * and sum the cached rest in the original order, so the result is
133
+ * byte-identical to a cold render; mute and solo only pick which stems are
134
+ * summed. Scratch buffers are reused across renders.
135
+ */
136
+ export class StemRenderer {
137
+ private readonly maxCacheBytes: number;
138
+ private readonly stems = new Map<string, Stem>();
139
+ private cacheBytes = 0;
140
+ private renders = 0;
141
+ private scratch: {
142
+ dry: Float64Array;
143
+ left: Float64Array;
144
+ right: Float64Array;
145
+ mixL: Float64Array;
146
+ mixR: Float64Array;
147
+ } = {
148
+ dry: new Float64Array(0),
149
+ left: new Float64Array(0),
150
+ right: new Float64Array(0),
151
+ mixL: new Float64Array(0),
152
+ mixR: new Float64Array(0),
153
+ };
154
+
155
+ public constructor(options: Readonly<{ maxCacheBytes?: number }> = {}) {
156
+ this.maxCacheBytes = Math.max(
157
+ 0,
158
+ options.maxCacheBytes ?? DEFAULT_STEM_CACHE_BYTES,
159
+ );
160
+ }
161
+
162
+ /** Cached stems and their total size, for tests and diagnostics. */
163
+ public get cache(): Readonly<{ stems: number; bytes: number }> {
164
+ return { stems: this.stems.size, bytes: this.cacheBytes };
165
+ }
166
+
167
+ public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
168
+ const sampleRate = clampSampleRate(options.sampleRate);
169
+ const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
170
+ const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
171
+ const reverbTail = Math.max(
172
+ 0,
173
+ ...score.tracks.map((track) =>
174
+ track.reverb && track.reverb.mix > 0 ? 1 + 3 * track.reverb.size : 0,
118
175
  ),
119
176
  );
120
- const tailSeconds = Math.min(
177
+ const bank = options.samples ?? EMPTY_SAMPLE_BANK;
178
+ // Zero without sampler voices, so synth-only renders are unchanged.
179
+ const samplerTail = Math.min(
121
180
  MAX_LOOP_TAIL_SECONDS,
122
- MAX_DRUM_SECONDS + 0.1 + reverbTail + delayTailSeconds(score),
123
- );
124
- samples = frames + Math.ceil(tailSeconds * sampleRate);
125
- } else {
126
- const seconds = Math.min(
127
- maxSeconds,
128
- loopSeconds + ONE_SHOT_TAIL_SECONDS + reverbTail,
181
+ samplerTailSeconds(score, bank, sampleRate),
129
182
  );
130
- frames = Math.max(1, Math.ceil(seconds * sampleRate));
131
- samples = frames;
132
- }
133
- const context: RenderContext = {
134
- score,
135
- sampleRate,
136
- samples,
137
- samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
138
- };
139
- // Tracks render one at a time into reused scratch buffers so per-track
140
- // effects stay bounded in memory regardless of the track count.
141
- const mixL = new Float64Array(samples);
142
- const mixR = new Float64Array(samples);
143
- const dry = new Float64Array(samples);
144
- const left = new Float64Array(samples);
145
- const right = new Float64Array(samples);
146
- const tracks = new Map(score.tracks.map((track) => [track.id, track]));
147
- const groups = new Map<string, Note[]>();
148
- for (const note of score.notes) {
149
- const group = groups.get(note.trackId);
150
- if (group) group.push(note);
151
- else groups.set(note.trackId, [note]);
152
- }
153
- for (const [trackId, notes] of groups) {
154
- if (!isTrackAudible(score, trackId)) continue;
155
- const track = tracks.get(trackId);
156
- dry.fill(0);
157
- const drums = isDrumInstrument(track?.instrument);
158
- for (const note of notes) {
159
- if (drums) renderDrumNote(dry, note, track, context);
160
- else renderToneNote(dry, note, track, context);
183
+ let frames: number;
184
+ let samples: number;
185
+ if (options.loop) {
186
+ frames = Math.max(
187
+ 1,
188
+ Math.min(
189
+ Math.round(loopFrames(score, sampleRate)),
190
+ Math.ceil(maxSeconds * sampleRate),
191
+ ),
192
+ );
193
+ const tailSeconds = Math.min(
194
+ MAX_LOOP_TAIL_SECONDS,
195
+ Math.max(MAX_DRUM_SECONDS, samplerTail) +
196
+ 0.1 +
197
+ reverbTail +
198
+ delayTailSeconds(score),
199
+ );
200
+ samples = frames + Math.ceil(tailSeconds * sampleRate);
201
+ } else {
202
+ const seconds = Math.min(
203
+ maxSeconds,
204
+ loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail,
205
+ );
206
+ frames = Math.max(1, Math.ceil(seconds * sampleRate));
207
+ samples = frames;
161
208
  }
162
- if (track) applyLowPass(dry, track, context);
163
- applyPan(dry, left, right, track, context);
164
- if (track) {
165
- applyDelay(left, right, track, context);
166
- applyReverb(left, right, track, context);
209
+ const context: RenderContext = {
210
+ score,
211
+ sampleRate,
212
+ samples,
213
+ samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
214
+ };
215
+ this.renders += 1;
216
+ const { dry, left, right, mixL, mixR } = this.scratchFor(samples);
217
+ mixL.fill(0);
218
+ mixR.fill(0);
219
+ const tracks = new Map(score.tracks.map((track) => [track.id, track]));
220
+ const groups = new Map<string, Note[]>();
221
+ for (const note of score.notes) {
222
+ const group = groups.get(note.trackId);
223
+ if (group) group.push(note);
224
+ else groups.set(note.trackId, [note]);
167
225
  }
168
- for (let index = 0; index < samples; index += 1) {
169
- mixL[index]! += left[index]!;
170
- mixR[index]! += right[index]!;
226
+ // Tracks render one at a time; the sum order (first note per track) is
227
+ // part of the output, so cached and cold renders keep it.
228
+ for (const [trackId, notes] of groups) {
229
+ if (!isTrackAudible(score, trackId)) continue;
230
+ const track = tracks.get(trackId);
231
+ const sampler = isSamplerInstrument(track?.instrument);
232
+ const key = stemKey(track, notes, context, sampler ? bank : undefined);
233
+ let stem = this.stems.get(trackId);
234
+ if (stem?.key === key) stem.used = this.renders;
235
+ else {
236
+ const caching = this.maxCacheBytes > 0;
237
+ const target = caching
238
+ ? {
239
+ left: new Float64Array(samples),
240
+ right: new Float64Array(samples),
241
+ }
242
+ : { left, right };
243
+ dry.fill(0);
244
+ if (sampler) {
245
+ if (track) renderSamplerNotes(dry, notes, track, context, bank);
246
+ } else {
247
+ const drums = isDrumInstrument(track?.instrument);
248
+ for (const note of notes) {
249
+ if (drums) renderDrumNote(dry, note, track, context);
250
+ else renderToneNote(dry, note, track, context);
251
+ }
252
+ }
253
+ if (track) applyLowPass(dry, track, context);
254
+ applyPan(dry, target.left, target.right, track, context);
255
+ if (track) {
256
+ applyDelay(target.left, target.right, track, context);
257
+ applyReverb(target.left, target.right, track, context);
258
+ }
259
+ stem = {
260
+ key,
261
+ left: target.left,
262
+ right: target.right,
263
+ bytes: target.left.byteLength + target.right.byteLength,
264
+ used: this.renders,
265
+ };
266
+ if (caching) this.store(trackId, stem);
267
+ }
268
+ for (let index = 0; index < samples; index += 1) {
269
+ mixL[index]! += stem.left[index]!;
270
+ mixR[index]! += stem.right[index]!;
271
+ }
171
272
  }
273
+ // Stems of tracks that left the score are not worth keeping; muted and
274
+ // unsoloed tracks keep theirs so toggling them back is free.
275
+ for (const trackId of [...this.stems.keys()])
276
+ if (!groups.has(trackId)) this.evict(trackId);
277
+ if (samples > frames) {
278
+ // Linear effects superpose, so folding the tail onto the start yields
279
+ // the steady state of the loop playing forever.
280
+ for (let index = frames; index < samples; index += 1) {
281
+ mixL[index % frames]! += mixL[index]!;
282
+ mixR[index % frames]! += mixR[index]!;
283
+ }
284
+ }
285
+ const pcm = new Int16Array(frames * RENDER_CHANNELS);
286
+ for (let index = 0; index < frames; index += 1) {
287
+ pcm[index * 2] = clamp16(mixL[index]! * 32767);
288
+ pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
289
+ }
290
+ return Object.freeze({
291
+ sampleRate,
292
+ channels: RENDER_CHANNELS,
293
+ frames,
294
+ pcm,
295
+ });
172
296
  }
173
- if (samples > frames) {
174
- // Linear effects superpose, so folding the tail onto the start yields
175
- // the steady state of the loop playing forever.
176
- for (let index = frames; index < samples; index += 1) {
177
- mixL[index % frames]! += mixL[index]!;
178
- mixR[index % frames]! += mixR[index]!;
297
+
298
+ private scratchFor(samples: number) {
299
+ if (this.scratch.dry.length < samples) {
300
+ this.scratch = {
301
+ dry: new Float64Array(samples),
302
+ left: new Float64Array(samples),
303
+ right: new Float64Array(samples),
304
+ mixL: new Float64Array(samples),
305
+ mixR: new Float64Array(samples),
306
+ };
179
307
  }
308
+ const view = (buffer: Float64Array) => buffer.subarray(0, samples);
309
+ return {
310
+ dry: view(this.scratch.dry),
311
+ left: view(this.scratch.left),
312
+ right: view(this.scratch.right),
313
+ mixL: view(this.scratch.mixL),
314
+ mixR: view(this.scratch.mixR),
315
+ };
180
316
  }
181
- const pcm = new Int16Array(frames * RENDER_CHANNELS);
182
- for (let index = 0; index < frames; index += 1) {
183
- pcm[index * 2] = clamp16(mixL[index]! * 32767);
184
- pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
317
+
318
+ private store(trackId: string, stem: Stem): void {
319
+ this.evict(trackId);
320
+ // Make room by dropping the least recently used stems from other
321
+ // renders; a score whose stems exceed the budget is simply not cached.
322
+ while (
323
+ this.cacheBytes + stem.bytes > this.maxCacheBytes &&
324
+ this.stems.size > 0
325
+ ) {
326
+ let oldest: string | undefined;
327
+ for (const [id, candidate] of this.stems)
328
+ if (
329
+ candidate.used !== this.renders &&
330
+ (oldest === undefined ||
331
+ candidate.used < this.stems.get(oldest)!.used)
332
+ )
333
+ oldest = id;
334
+ if (oldest === undefined) break;
335
+ this.evict(oldest);
336
+ }
337
+ if (this.cacheBytes + stem.bytes > this.maxCacheBytes) return;
338
+ this.stems.set(trackId, stem);
339
+ this.cacheBytes += stem.bytes;
340
+ }
341
+
342
+ private evict(trackId: string): void {
343
+ const stem = this.stems.get(trackId);
344
+ if (!stem) return;
345
+ this.stems.delete(trackId);
346
+ this.cacheBytes -= stem.bytes;
185
347
  }
186
- return Object.freeze({ sampleRate, channels: RENDER_CHANNELS, frames, pcm });
348
+ }
349
+
350
+ /** Everything a track's stem depends on, except mute and solo. */
351
+ function stemKey(
352
+ track: Track | undefined,
353
+ notes: readonly Note[],
354
+ context: RenderContext,
355
+ bank?: SampleBank,
356
+ ): string {
357
+ let settings: Record<string, unknown> | null = null;
358
+ if (track) {
359
+ const { muted: _muted, solo: _solo, ...rest } = track;
360
+ settings = rest;
361
+ }
362
+ return JSON.stringify([
363
+ settings,
364
+ notes,
365
+ context.score.tempoBpm,
366
+ context.score.ticksPerBeat,
367
+ context.sampleRate,
368
+ context.samples,
369
+ // Sampler stems also depend on the decoded files: a replaced or missing
370
+ // sample changes the key even when the score did not change.
371
+ ...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
372
+ ]);
373
+ }
374
+
375
+ function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
376
+ return Object.keys(track.sampler?.voices ?? {})
377
+ .sort()
378
+ .map((voice) => {
379
+ const sample = bank.voices.get(sampleKey(track.id, voice));
380
+ return sample
381
+ ? `${voice}:${sample.sha256}:${sample.sampleRate}:${sample.frames}`
382
+ : `${voice}:-`;
383
+ });
384
+ }
385
+
386
+ /** Sample voices of a sampler track into its mono dry buffer. */
387
+ function renderSamplerNotes(
388
+ target: Float64Array,
389
+ notes: readonly Note[],
390
+ track: Track,
391
+ context: RenderContext,
392
+ bank: SampleBank,
393
+ ): void {
394
+ const timing = { score: context.score, sampleRate: context.sampleRate };
395
+ renderSamplerVoices(
396
+ target,
397
+ planSamplerVoices(track, notes, bank, timing),
398
+ timing,
399
+ (tick) => trackGainAt(track, tick),
400
+ );
187
401
  }
188
402
 
189
403
  /** Seconds until the slowest delay decays below -60 dB, bounded. */
package/src/auth/cli.ts CHANGED
@@ -1,26 +1,44 @@
1
1
  import { hostname } from "node:os";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { stdin, stdout } from "node:process";
4
- import { defaultAuthEnv } from "./credentials.ts";
4
+ import {
5
+ defaultAuthEnv,
6
+ readConfig,
7
+ writeConfig,
8
+ type DawgConfig,
9
+ } from "./credentials.ts";
5
10
  import {
6
11
  authStatus,
12
+ isLogoutTarget,
7
13
  login,
8
14
  logout,
15
+ modelCommand,
9
16
  type LoginIO,
10
- type LoginMode,
17
+ type LoginOptions,
18
+ type LoginTarget,
11
19
  } from "./login.ts";
12
- import { systemRunner } from "./runner.ts";
20
+ import { LOGIN_OPTIONS } from "./discover.ts";
21
+ import { chooseOnTerminal } from "./picker.ts";
22
+ import { systemRunner, type CommandRunner } from "./runner.ts";
23
+ import { selectProvider } from "../agent/provider.ts";
13
24
  import { audioStatusLine } from "../audio/engine.ts";
14
25
 
15
26
  export const AUTH_USAGE = `Usage:
16
- dawg login create an AI Gateway key with the Vercel CLI (default)
17
- dawg login --key paste an existing AI Gateway key (hidden input)
18
- dawg login --xcb use a Claude/Codex/Devin subscription through xcb
19
- dawg login --budget <dollars> spend limit for the created key
20
- dawg logout remove the stored key and provider choice
21
- dawg auth status [--check] show provider, key, xcb account, audio backend`;
27
+ dawg login find existing setups and pick a provider (reuses a saved one)
28
+ dawg login gateway Vercel AI Gateway: existing key, or create one with the Vercel CLI
29
+ dawg login openrouter OpenRouter: sign in through your browser (OAuth PKCE)
30
+ dawg login codex ChatGPT/Codex subscription through xcb
31
+ dawg login claude Claude subscription through xcb
32
+ dawg login --pick show the picker even when a provider is saved
33
+ dawg login --key paste an AI Gateway key (hidden input)
34
+ dawg login --xcb any xcb account (Devin included)
35
+ dawg login gateway --budget <dollars> spend limit for a created key
36
+ dawg login codex|claude [--account <id>] [--model <key>]
37
+ dawg model [<alias>|<vendor/model>] pick the model (cost per prompt shown)
38
+ dawg logout [gateway|openrouter|codex|claude|xcb] forget keys and the saved choice
39
+ dawg auth status [--check] every provider: detected, active, validated`;
22
40
 
23
- /** Entry for `dawg login|logout|auth`. Returns the process exit code. */
41
+ /** Entry for `dawg login|logout|auth|model`. Returns the process exit code. */
24
42
  export async function runAuthCommand(argv: readonly string[]): Promise<number> {
25
43
  const [command, ...rest] = argv;
26
44
  const io = terminalIO();
@@ -29,7 +47,15 @@ export async function runAuthCommand(argv: readonly string[]): Promise<number> {
29
47
  io.print(AUTH_USAGE);
30
48
  return 0;
31
49
  }
32
- if (command === "logout") return logout(deps);
50
+ if (command === "logout") {
51
+ const target = rest[0];
52
+ if (target !== undefined && !isLogoutTarget(target)) {
53
+ io.print(AUTH_USAGE);
54
+ return 2;
55
+ }
56
+ return logout(deps, target);
57
+ }
58
+ if (command === "model") return modelCommand(deps, rest[0]);
33
59
  if (command === "auth") {
34
60
  if (
35
61
  rest[0] !== undefined &&
@@ -41,30 +67,117 @@ export async function runAuthCommand(argv: readonly string[]): Promise<number> {
41
67
  }
42
68
  for (const line of await authStatus(deps, {
43
69
  verify: rest.includes("--check"),
70
+ onProgress: (line) => io.print(line),
44
71
  }))
45
72
  io.print(line);
46
73
  io.print(audioStatusLine());
47
74
  return 0;
48
75
  }
49
- const flags = new Set(rest.filter((arg) => arg.startsWith("--")));
50
- const mode: LoginMode = flags.has("--xcb")
51
- ? "xcb"
52
- : flags.has("--key")
53
- ? "key"
54
- : "gateway";
55
- let budget: number | undefined;
56
- const budgetIndex = rest.indexOf("--budget");
57
- if (budgetIndex >= 0) {
58
- budget = Number(rest[budgetIndex + 1]);
59
- if (!Number.isFinite(budget) || budget < 1 || budget > 1_000_000) {
60
- io.print("--budget takes a dollar amount of at least 1");
61
- return 2;
62
- }
76
+ const parsed = parseLoginArgs(rest);
77
+ if (typeof parsed === "string") {
78
+ io.print(parsed);
79
+ io.print(AUTH_USAGE);
80
+ return 2;
81
+ }
82
+ return login(parsed.target, deps, parsed.options);
83
+ }
84
+
85
+ /**
86
+ * The first run of `dawg` in a terminal: when nothing is configured (and the
87
+ * picker was never dismissed), or a saved sign-in stopped working, show the
88
+ * sign-in picker before the TUI starts. Dismissing it is remembered.
89
+ */
90
+ export async function runFirstRunLogin(
91
+ runner: CommandRunner = systemRunner,
92
+ ): Promise<void> {
93
+ const auth = defaultAuthEnv(runner);
94
+ const config = await readConfig(auth).catch(() => ({}) as DawgConfig);
95
+ if (auth.env.DAWG_PROVIDER) return;
96
+ if (!config.provider && config.setup === "skipped") return;
97
+ const selection = await selectProvider(auth).catch(() => undefined);
98
+ if (!selection || selection.kind !== "offline") return;
99
+ if (config.provider && !selection.invalidSaved) return;
100
+ const io = terminalIO();
101
+ if (!io.interactive) return;
102
+ if (selection.invalidSaved)
103
+ io.print(`Your saved sign-in stopped working: ${selection.reason}.`);
104
+ else io.print("Welcome to dawg. Sign in to enable the agent (Esc to skip).");
105
+ const code = await login("pick", {
106
+ auth,
107
+ io,
108
+ hostname: hostname(),
109
+ }).catch((error: unknown) => {
110
+ io.print(
111
+ `sign-in failed: ${error instanceof Error ? error.message : String(error)}`,
112
+ );
113
+ return 1;
114
+ });
115
+ if (code !== 0 && !config.provider)
116
+ await writeConfig(auth, { setup: "skipped" }).catch(() => undefined);
117
+ }
118
+
119
+ /** `/login` in the TUI, run while the screen is handed back to the shell. */
120
+ export async function runTuiLogin(
121
+ target: LoginTarget,
122
+ options: LoginOptions,
123
+ runner: CommandRunner = systemRunner,
124
+ ): Promise<number> {
125
+ const io = terminalIO();
126
+ return login(
127
+ target,
128
+ {
129
+ auth: defaultAuthEnv(runner),
130
+ io,
131
+ hostname: hostname(),
132
+ },
133
+ options,
134
+ ).catch((error: unknown) => {
135
+ io.print(
136
+ `sign-in failed: ${error instanceof Error ? error.message : String(error)}`,
137
+ );
138
+ return 1;
139
+ });
140
+ }
141
+
142
+ export function parseLoginArgs(
143
+ args: readonly string[],
144
+ ): { target: LoginTarget; options: LoginOptions } | string {
145
+ const options: LoginOptions = {};
146
+ let target: LoginTarget = "auto";
147
+ for (let index = 0; index < args.length; index += 1) {
148
+ const arg = args[index]!;
149
+ const value = () => {
150
+ const next = args[index + 1];
151
+ index += 1;
152
+ return next && !next.startsWith("--") ? next : undefined;
153
+ };
154
+ if ((LOGIN_OPTIONS as readonly string[]).includes(arg))
155
+ target = arg as LoginTarget;
156
+ else if (arg === "--gateway") target = "gateway";
157
+ else if (arg === "--openrouter") target = "openrouter";
158
+ else if (arg === "--xcb") target = "xcb";
159
+ else if (arg === "--key") target = "key";
160
+ else if (arg === "--pick") target = "pick";
161
+ else if (arg === "--budget") {
162
+ const budget = Number(value());
163
+ if (!Number.isFinite(budget) || budget < 1 || budget > 1_000_000)
164
+ return "--budget takes a dollar amount of at least 1";
165
+ options.budget = budget;
166
+ if (target === "auto") target = "gateway";
167
+ } else if (arg === "--account") {
168
+ const account = value();
169
+ if (!account) return "--account needs an account id";
170
+ options.account = account;
171
+ } else if (arg === "--model") {
172
+ const model = value();
173
+ if (!model) return "--model needs a model key";
174
+ options.model = model;
175
+ } else return `unknown argument: ${arg.slice(0, 40)}`;
63
176
  }
64
- return login(mode, deps, budget === undefined ? {} : { budget });
177
+ return { target, options };
65
178
  }
66
179
 
67
- function terminalIO(): LoginIO {
180
+ export function terminalIO(): LoginIO {
68
181
  return {
69
182
  interactive: Boolean(stdin.isTTY && stdout.isTTY),
70
183
  print: (line) => void stdout.write(`${line}\n`),
@@ -77,6 +190,12 @@ function terminalIO(): LoginIO {
77
190
  }
78
191
  },
79
192
  readSecret: (prompt) => readHidden(prompt),
193
+ ...(stdin.isTTY && stdout.isTTY
194
+ ? {
195
+ choose: (title, items, options) =>
196
+ chooseOnTerminal(title, items, options),
197
+ }
198
+ : {}),
80
199
  };
81
200
  }
82
201