@hraness/dawg 0.2.0 → 0.4.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.
- package/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +397 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/media/wavetable.ts +202 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
package/src/auth/login.ts
CHANGED
|
@@ -1,22 +1,67 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
clearKey,
|
|
3
3
|
isValidKey,
|
|
4
|
+
keyEnvName,
|
|
4
5
|
maskKey,
|
|
5
6
|
readConfig,
|
|
6
7
|
resolveGatewayKey,
|
|
7
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
+
id: LoginOption,
|
|
211
|
+
found: Discovery,
|
|
212
|
+
options: LoginOptions,
|
|
50
213
|
): Promise<number> {
|
|
51
|
-
if (
|
|
52
|
-
if (
|
|
53
|
-
return
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
416
|
+
`${service} key (input hidden, Enter to open the key page): `,
|
|
170
417
|
)
|
|
171
418
|
).trim();
|
|
172
419
|
if (!entered) {
|
|
173
|
-
await openUrl(deps,
|
|
420
|
+
await openUrl(deps, page, "the key page");
|
|
174
421
|
continue;
|
|
175
422
|
}
|
|
176
423
|
if (!isValidKey(entered)) {
|
|
177
424
|
io.print(
|
|
178
|
-
|
|
425
|
+
`That does not look like an ${service} key (letters, digits, - _ . only).`,
|
|
179
426
|
);
|
|
180
427
|
continue;
|
|
181
428
|
}
|
|
182
|
-
const check = await
|
|
429
|
+
const check = await checkServiceKey(deps, kind, entered);
|
|
183
430
|
if (check === "rejected") {
|
|
184
|
-
io.print(
|
|
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
|
|
201
|
-
await
|
|
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
|
|
453
|
+
const check = knownCheck ?? (await checkServiceKey(deps, kind, key));
|
|
206
454
|
io.print(
|
|
207
455
|
check === "valid"
|
|
208
|
-
?
|
|
456
|
+
? `✓ ${service} accepted the key.`
|
|
209
457
|
: check === "rejected"
|
|
210
|
-
?
|
|
211
|
-
:
|
|
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 (
|
|
461
|
+
if (check === "rejected") return 1;
|
|
462
|
+
await selectService(deps, kind);
|
|
463
|
+
if (auth.env[keyEnvName(kind)])
|
|
214
464
|
io.print(
|
|
215
|
-
|
|
465
|
+
`Note: ${keyEnvName(kind)} is set in your environment and takes precedence.`,
|
|
216
466
|
);
|
|
217
|
-
|
|
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.
|
|
250
|
-
*
|
|
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 (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
provider
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
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 (
|
|
351
|
-
io.print(
|
|
352
|
-
|
|
353
|
-
|
|
1006
|
+
if (index === undefined) {
|
|
1007
|
+
io.print(`model · ${providerLabel(selection)} (unchanged)`);
|
|
1008
|
+
return io.interactive ? 0 : 0;
|
|
1009
|
+
}
|
|
354
1010
|
io.print(
|
|
355
|
-
|
|
1011
|
+
`model · ${await saveModelChoice(auth, selection, rows[index]!.value)}`,
|
|
356
1012
|
);
|
|
357
1013
|
return 0;
|
|
358
1014
|
}
|
|
359
1015
|
|
|
360
|
-
|
|
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
|
|
365
|
-
await
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
?
|
|
369
|
-
:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
/**
|
|
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<
|
|
380
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 };
|