@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.
Files changed (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
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
 
@@ -15,30 +15,81 @@ import type { CommandRunner } from "./runner.ts";
15
15
  /**
16
16
  * Local credentials and provider choice. Secrets live in the macOS Keychain
17
17
  * or `~/.config/dawg/credentials.json` (0600 under a 0700 directory), never in
18
- * the project's `.dawg/` directory. `AI_GATEWAY_API_KEY` always wins.
18
+ * the project's `.dawg/` directory. `AI_GATEWAY_API_KEY` and
19
+ * `OPENROUTER_API_KEY` always win over stored keys.
19
20
  */
20
21
  export const KEYCHAIN_SERVICE = "dawg";
21
22
  export const CONFIG_DIR_NAME = "dawg";
22
23
  export const KEYCHAIN_ACCOUNT = "ai-gateway";
24
+
25
+ /** The two key-based providers. Subscriptions keep their secrets in xcb. */
26
+ export type KeyKind = "gateway" | "openrouter";
27
+ type KeySpec = Readonly<{
28
+ account: string;
29
+ field: string;
30
+ env: string;
31
+ label: string;
32
+ }>;
33
+ const KEY_SPECS: Readonly<Record<KeyKind, KeySpec>> = Object.freeze({
34
+ gateway: {
35
+ account: KEYCHAIN_ACCOUNT,
36
+ field: "aiGateway",
37
+ env: "AI_GATEWAY_API_KEY",
38
+ label: "dawg AI Gateway key",
39
+ },
40
+ openrouter: {
41
+ account: "openrouter",
42
+ field: "openrouter",
43
+ env: "OPENROUTER_API_KEY",
44
+ label: "dawg OpenRouter key",
45
+ },
46
+ });
47
+ /** A Vercel OIDC token is a JWT; bound it and keep the alphabet quote-safe. */
48
+ const OIDC_PATTERN = /^[A-Za-z0-9._-]{32,8192}$/;
23
49
  const MAX_CONFIG_BYTES = 16 * 1024;
24
50
  /** Gateway keys are opaque tokens; restricting the alphabet keeps them quote-safe. */
25
51
  const KEY_PATTERN = /^[A-Za-z0-9._-]{16,256}$/;
26
52
  const XCB_ACCOUNT_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
27
53
  const XCB_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,200}$/;
28
54
 
55
+ /**
56
+ * `codex` and `claude` are subscriptions through xcb; `xcb` is any xcb
57
+ * account (Devin included), kept for `dawg login --xcb`.
58
+ */
29
59
  export const PROVIDER_CHOICES = Object.freeze([
30
60
  "gateway",
61
+ "openrouter",
62
+ "codex",
63
+ "claude",
31
64
  "xcb",
32
65
  "auto",
33
66
  ] as const);
34
67
  export type ProviderChoice = (typeof PROVIDER_CHOICES)[number];
68
+ export const SUBSCRIPTION_SLOTS = Object.freeze([
69
+ "codex",
70
+ "claude",
71
+ "xcb",
72
+ ] as const);
73
+ export type SubscriptionSlot = (typeof SUBSCRIPTION_SLOTS)[number];
74
+ export type XcbPick = Readonly<{ account: string; model: string }>;
35
75
 
36
76
  export type DawgConfig = Readonly<{
37
77
  provider?: ProviderChoice;
38
- xcb?: Readonly<{ account: string; model: string }>;
78
+ xcb?: XcbPick;
79
+ codex?: XcbPick;
80
+ claude?: XcbPick;
81
+ /** Last model chosen per key-based provider: a `provider/model` ID. */
82
+ gatewayModel?: string;
83
+ openrouterModel?: string;
84
+ /** Set when the first-run picker was dismissed, so it does not nag. */
85
+ setup?: "skipped";
39
86
  }>;
40
87
 
41
- export type CredentialSource = "env" | "keychain" | "file";
88
+ /** `vendor/model` IDs as the AI Gateway and OpenRouter spell them. */
89
+ export const PROVIDER_MODEL_ID_PATTERN =
90
+ /^[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/i;
91
+
92
+ export type CredentialSource = "env" | "keychain" | "file" | "oidc";
42
93
  export type ResolvedKey = Readonly<{ key: string; source: CredentialSource }>;
43
94
 
44
95
  export type AuthEnv = Readonly<{
@@ -74,6 +125,7 @@ export function isValidKey(value: unknown): value is string {
74
125
  /** `vck_…abcd`: never more than a short prefix and the last four characters. */
75
126
  export function maskKey(key: string): string {
76
127
  if (key.length < 12) return "…";
128
+ if (key.startsWith("sk-or-")) return `sk-or-…${key.slice(-4)}`;
77
129
  const underscore = key.indexOf("_");
78
130
  const prefix =
79
131
  underscore > 0 && underscore <= 4 ? key.slice(0, underscore + 1) : "";
@@ -89,42 +141,79 @@ function useKeychain(auth: AuthEnv): boolean {
89
141
  }
90
142
 
91
143
  /** Env, then Keychain, then the credentials file. */
92
- export async function resolveGatewayKey(
144
+ export async function resolveKey(
93
145
  auth: AuthEnv,
146
+ kind: KeyKind,
94
147
  ): Promise<ResolvedKey | undefined> {
95
- const fromEnv = auth.env.AI_GATEWAY_API_KEY?.trim();
148
+ const spec = KEY_SPECS[kind];
149
+ const fromEnv = auth.env[spec.env]?.trim();
96
150
  if (fromEnv) return { key: fromEnv, source: "env" };
151
+ const stored = await resolveStoredKey(auth, kind);
152
+ return stored;
153
+ }
154
+
155
+ /** The Keychain or the credentials file, ignoring the environment. */
156
+ export async function resolveStoredKey(
157
+ auth: AuthEnv,
158
+ kind: KeyKind,
159
+ ): Promise<ResolvedKey | undefined> {
160
+ const spec = KEY_SPECS[kind];
97
161
  if (useKeychain(auth)) {
98
- const key = (await readKeychain(auth, KEYCHAIN_SERVICE)) ?? "";
162
+ const key = (await readKeychain(auth, spec.account)) ?? "";
99
163
  if (isValidKey(key)) return { key, source: "keychain" };
100
164
  }
101
165
  const file = await readJsonObject(join(auth.dir, "credentials.json"));
102
- const key = file?.aiGateway;
166
+ const key = file?.[spec.field];
103
167
  if (isValidKey(key)) return { key, source: "file" };
104
168
  return undefined;
105
169
  }
106
170
 
107
- /** Store the key. Returns where it went. Throws on an invalid key. */
108
- export async function storeGatewayKey(
171
+ /**
172
+ * The gateway credential: `AI_GATEWAY_API_KEY`, a stored key, then a
173
+ * `VERCEL_OIDC_TOKEN` (what `vercel env pull` writes for a linked project;
174
+ * AI Gateway accepts it as a bearer token until it expires).
175
+ */
176
+ export async function resolveGatewayKey(
177
+ auth: AuthEnv,
178
+ ): Promise<ResolvedKey | undefined> {
179
+ const key = await resolveKey(auth, "gateway");
180
+ if (key) return key;
181
+ const oidc = auth.env.VERCEL_OIDC_TOKEN?.trim();
182
+ if (oidc && OIDC_PATTERN.test(oidc)) return { key: oidc, source: "oidc" };
183
+ return undefined;
184
+ }
185
+
186
+ export function resolveOpenRouterKey(
109
187
  auth: AuthEnv,
188
+ ): Promise<ResolvedKey | undefined> {
189
+ return resolveKey(auth, "openrouter");
190
+ }
191
+
192
+ /** Store a key. Returns where it went. Throws on an invalid key. */
193
+ export async function storeKey(
194
+ auth: AuthEnv,
195
+ kind: KeyKind,
110
196
  key: string,
111
197
  ): Promise<"keychain" | "file"> {
198
+ const spec = KEY_SPECS[kind];
112
199
  if (!isValidKey(key))
113
- throw new Error("that does not look like an AI Gateway key");
200
+ throw new Error(
201
+ `that does not look like an ${kind === "gateway" ? "AI Gateway" : "OpenRouter"} key`,
202
+ );
114
203
  if (useKeychain(auth)) {
115
204
  // `security -i` reads the command from stdin, so the secret never appears
116
205
  // in any process's argv. KEY_PATTERN guarantees it needs no escaping.
117
206
  const result = await auth.runner
118
207
  .run("security", ["-i"], {
119
- stdin: `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${KEYCHAIN_ACCOUNT} -l "dawg AI Gateway key" -w "${key}"\n`,
208
+ stdin: `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${spec.account} -l "${spec.label}" -w "${key}"\n`,
120
209
  timeoutMs: 15_000,
121
210
  maxOutputBytes: 4096,
122
211
  })
123
212
  .catch(() => undefined);
124
213
  if (result?.code === 0) {
125
- const check = await resolveFromKeychainOnly(auth);
214
+ const check = await readKeychain(auth, spec.account);
126
215
  if (check === key) {
127
- await removeFileKey(auth);
216
+ await removeFileKey(auth, kind);
128
217
  return "keychain";
129
218
  }
130
219
  }
@@ -134,33 +223,37 @@ export async function storeGatewayKey(
134
223
  await writePrivateJson(auth.dir, path, {
135
224
  ...current,
136
225
  version: 1,
137
- aiGateway: key,
226
+ [spec.field]: key,
138
227
  });
139
228
  return "file";
140
229
  }
141
230
 
142
- async function resolveFromKeychainOnly(
231
+ export function storeGatewayKey(
143
232
  auth: AuthEnv,
144
- ): Promise<string | undefined> {
145
- return readKeychain(auth, KEYCHAIN_SERVICE);
233
+ key: string,
234
+ ): Promise<"keychain" | "file"> {
235
+ return storeKey(auth, "gateway", key);
146
236
  }
147
237
 
148
238
  async function readKeychain(
149
239
  auth: AuthEnv,
150
- service: string,
240
+ account: string,
151
241
  ): Promise<string | undefined> {
152
242
  const result = await auth.runner
153
243
  .run(
154
244
  "security",
155
- ["find-generic-password", "-s", service, "-a", KEYCHAIN_ACCOUNT, "-w"],
245
+ ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"],
156
246
  { timeoutMs: 10_000, maxOutputBytes: 4096 },
157
247
  )
158
248
  .catch(() => undefined);
159
249
  return result?.code === 0 ? result.stdout.trim() : undefined;
160
250
  }
161
251
 
162
- /** Remove stored keys from both backends. Env keys are left to the user. */
163
- export async function clearGatewayKey(auth: AuthEnv): Promise<string[]> {
252
+ /** Remove a stored key from both backends. Env keys are left to the user. */
253
+ export async function clearKey(
254
+ auth: AuthEnv,
255
+ kind: KeyKind,
256
+ ): Promise<string[]> {
164
257
  const removed: string[] = [];
165
258
  if (useKeychain(auth)) {
166
259
  const result = await auth.runner
@@ -171,22 +264,32 @@ export async function clearGatewayKey(auth: AuthEnv): Promise<string[]> {
171
264
  "-s",
172
265
  KEYCHAIN_SERVICE,
173
266
  "-a",
174
- KEYCHAIN_ACCOUNT,
267
+ KEY_SPECS[kind].account,
175
268
  ],
176
269
  { timeoutMs: 10_000, maxOutputBytes: 4096 },
177
270
  )
178
271
  .catch(() => undefined);
179
272
  if (result?.code === 0) removed.push("keychain");
180
273
  }
181
- if (await removeFileKey(auth)) removed.push("file");
274
+ if (await removeFileKey(auth, kind)) removed.push("file");
182
275
  return removed;
183
276
  }
184
277
 
185
- async function removeFileKey(auth: AuthEnv): Promise<boolean> {
278
+ export function clearGatewayKey(auth: AuthEnv): Promise<string[]> {
279
+ return clearKey(auth, "gateway");
280
+ }
281
+
282
+ /** The environment variable that overrides a stored key. */
283
+ export function keyEnvName(kind: KeyKind): string {
284
+ return KEY_SPECS[kind].env;
285
+ }
286
+
287
+ async function removeFileKey(auth: AuthEnv, kind: KeyKind): Promise<boolean> {
288
+ const field = KEY_SPECS[kind].field;
186
289
  const path = join(auth.dir, "credentials.json");
187
290
  const current = await readJsonObject(path);
188
- if (!current || !("aiGateway" in current)) return false;
189
- const { aiGateway: _removed, ...rest } = current;
291
+ if (!current || !(field in current)) return false;
292
+ const { [field]: _removed, ...rest } = current;
190
293
  if (Object.keys(rest).filter((key) => key !== "version").length === 0)
191
294
  await rm(path, { force: true });
192
295
  else await writePrivateJson(auth.dir, path, rest);
@@ -205,30 +308,53 @@ export function parseConfig(value: unknown): DawgConfig {
205
308
  const record = value as Record<string, unknown>;
206
309
  const config: {
207
310
  provider?: ProviderChoice;
208
- xcb?: { account: string; model: string };
311
+ xcb?: XcbPick;
312
+ codex?: XcbPick;
313
+ claude?: XcbPick;
314
+ gatewayModel?: string;
315
+ openrouterModel?: string;
316
+ setup?: "skipped";
209
317
  } = {};
210
318
  if (PROVIDER_CHOICES.includes(record.provider as ProviderChoice))
211
319
  config.provider = record.provider as ProviderChoice;
212
- const xcb = record.xcb;
213
- if (typeof xcb === "object" && xcb !== null && !Array.isArray(xcb)) {
214
- const { account, model } = xcb as Record<string, unknown>;
215
- if (
216
- typeof account === "string" &&
217
- XCB_ACCOUNT_PATTERN.test(account) &&
218
- typeof model === "string" &&
219
- XCB_MODEL_PATTERN.test(model)
220
- )
221
- config.xcb = { account, model };
320
+ for (const slot of SUBSCRIPTION_SLOTS) {
321
+ const pick = parsePick(record[slot]);
322
+ if (pick) config[slot] = pick;
222
323
  }
324
+ for (const field of ["gatewayModel", "openrouterModel"] as const) {
325
+ const id = record[field];
326
+ if (typeof id === "string" && PROVIDER_MODEL_ID_PATTERN.test(id))
327
+ config[field] = id;
328
+ }
329
+ if (record.setup === "skipped") config.setup = "skipped";
223
330
  return config;
224
331
  }
225
332
 
333
+ function parsePick(value: unknown): XcbPick | undefined {
334
+ if (typeof value !== "object" || value === null || Array.isArray(value))
335
+ return undefined;
336
+ const { account, model } = value as Record<string, unknown>;
337
+ return typeof account === "string" &&
338
+ XCB_ACCOUNT_PATTERN.test(account) &&
339
+ typeof model === "string" &&
340
+ XCB_MODEL_PATTERN.test(model)
341
+ ? { account, model }
342
+ : undefined;
343
+ }
344
+
345
+ export type ConfigPatch = Partial<{
346
+ provider: ProviderChoice | undefined;
347
+ xcb: XcbPick | undefined;
348
+ codex: XcbPick | undefined;
349
+ claude: XcbPick | undefined;
350
+ gatewayModel: string | undefined;
351
+ openrouterModel: string | undefined;
352
+ setup: "skipped" | undefined;
353
+ }>;
354
+
226
355
  export async function writeConfig(
227
356
  auth: Pick<AuthEnv, "dir">,
228
- patch: Partial<{
229
- provider: ProviderChoice | undefined;
230
- xcb: DawgConfig["xcb"] | undefined;
231
- }>,
357
+ patch: ConfigPatch,
232
358
  ): Promise<DawgConfig> {
233
359
  const next: Record<string, unknown> = { ...(await readConfig(auth)) };
234
360
  for (const [key, value] of Object.entries(patch)) {