@hraness/dawg 0.0.0-stage → 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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,231 @@
1
+ import { hostname } from "node:os";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin, stdout } from "node:process";
4
+ import {
5
+ defaultAuthEnv,
6
+ readConfig,
7
+ writeConfig,
8
+ type DawgConfig,
9
+ } from "./credentials.ts";
10
+ import {
11
+ authStatus,
12
+ isLogoutTarget,
13
+ login,
14
+ logout,
15
+ modelCommand,
16
+ type LoginIO,
17
+ type LoginOptions,
18
+ type LoginTarget,
19
+ } from "./login.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";
24
+ import { audioStatusLine } from "../audio/engine.ts";
25
+
26
+ export const AUTH_USAGE = `Usage:
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`;
40
+
41
+ /** Entry for `dawg login|logout|auth|model`. Returns the process exit code. */
42
+ export async function runAuthCommand(argv: readonly string[]): Promise<number> {
43
+ const [command, ...rest] = argv;
44
+ const io = terminalIO();
45
+ const deps = { auth: defaultAuthEnv(systemRunner), io, hostname: hostname() };
46
+ if (rest.includes("--help") || rest.includes("-h")) {
47
+ io.print(AUTH_USAGE);
48
+ return 0;
49
+ }
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]);
59
+ if (command === "auth") {
60
+ if (
61
+ rest[0] !== undefined &&
62
+ rest[0] !== "status" &&
63
+ rest[0] !== "--check"
64
+ ) {
65
+ io.print(AUTH_USAGE);
66
+ return 2;
67
+ }
68
+ for (const line of await authStatus(deps, {
69
+ verify: rest.includes("--check"),
70
+ onProgress: (line) => io.print(line),
71
+ }))
72
+ io.print(line);
73
+ io.print(audioStatusLine());
74
+ return 0;
75
+ }
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)}`;
176
+ }
177
+ return { target, options };
178
+ }
179
+
180
+ export function terminalIO(): LoginIO {
181
+ return {
182
+ interactive: Boolean(stdin.isTTY && stdout.isTTY),
183
+ print: (line) => void stdout.write(`${line}\n`),
184
+ async ask(prompt) {
185
+ const rl = createInterface({ input: stdin, output: stdout });
186
+ try {
187
+ return (await rl.question(prompt)).slice(0, 200);
188
+ } finally {
189
+ rl.close();
190
+ }
191
+ },
192
+ readSecret: (prompt) => readHidden(prompt),
193
+ ...(stdin.isTTY && stdout.isTTY
194
+ ? {
195
+ choose: (title, items, options) =>
196
+ chooseOnTerminal(title, items, options),
197
+ }
198
+ : {}),
199
+ };
200
+ }
201
+
202
+ /** Read one line in raw mode without echo. Ctrl-C rejects. */
203
+ function readHidden(prompt: string): Promise<string> {
204
+ return new Promise((resolve, reject) => {
205
+ stdout.write(prompt);
206
+ let value = "";
207
+ const wasRaw = stdin.isRaw;
208
+ stdin.setRawMode?.(true);
209
+ stdin.resume();
210
+ const done = (error?: Error) => {
211
+ stdin.off("data", onData);
212
+ stdin.setRawMode?.(wasRaw);
213
+ stdin.pause();
214
+ stdout.write("\n");
215
+ if (error) reject(error);
216
+ else resolve(value);
217
+ };
218
+ const onData = (chunk: Buffer) => {
219
+ let text = chunk.toString("utf8");
220
+ // Bracketed paste markers, if the terminal sends them.
221
+ text = text.replace(/\u001b\[20[01]~/g, "");
222
+ for (const char of text) {
223
+ if (char === "\u0003") return done(new Error("cancelled"));
224
+ if (char === "\r" || char === "\n") return done();
225
+ if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
226
+ else if (char >= " " && value.length < 512) value += char;
227
+ }
228
+ };
229
+ stdin.on("data", onData);
230
+ });
231
+ }
@@ -0,0 +1,411 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ mkdir,
5
+ open,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ stat,
10
+ } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import type { CommandRunner } from "./runner.ts";
14
+
15
+ /**
16
+ * Local credentials and provider choice. Secrets live in the macOS Keychain
17
+ * or `~/.config/dawg/credentials.json` (0600 under a 0700 directory), never in
18
+ * the project's `.dawg/` directory. `AI_GATEWAY_API_KEY` and
19
+ * `OPENROUTER_API_KEY` always win over stored keys.
20
+ */
21
+ export const KEYCHAIN_SERVICE = "dawg";
22
+ export const CONFIG_DIR_NAME = "dawg";
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}$/;
49
+ const MAX_CONFIG_BYTES = 16 * 1024;
50
+ /** Gateway keys are opaque tokens; restricting the alphabet keeps them quote-safe. */
51
+ const KEY_PATTERN = /^[A-Za-z0-9._-]{16,256}$/;
52
+ const XCB_ACCOUNT_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
53
+ const XCB_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,200}$/;
54
+
55
+ /**
56
+ * `codex` and `claude` are subscriptions through xcb; `xcb` is any xcb
57
+ * account (Devin included), kept for `dawg login --xcb`.
58
+ */
59
+ export const PROVIDER_CHOICES = Object.freeze([
60
+ "gateway",
61
+ "openrouter",
62
+ "codex",
63
+ "claude",
64
+ "xcb",
65
+ "auto",
66
+ ] as const);
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 }>;
75
+
76
+ export type DawgConfig = Readonly<{
77
+ provider?: ProviderChoice;
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";
86
+ }>;
87
+
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";
93
+ export type ResolvedKey = Readonly<{ key: string; source: CredentialSource }>;
94
+
95
+ export type AuthEnv = Readonly<{
96
+ env: Readonly<Record<string, string | undefined>>;
97
+ runner: CommandRunner;
98
+ platform: string;
99
+ /** Absolute config directory; see `configDir`. */
100
+ dir: string;
101
+ }>;
102
+
103
+ export function configDir(
104
+ env: Readonly<Record<string, string | undefined>> = process.env,
105
+ ): string {
106
+ const explicit = env.DAWG_CONFIG_DIR;
107
+ if (explicit) return explicit;
108
+ const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
109
+ return join(base, CONFIG_DIR_NAME);
110
+ }
111
+
112
+ export function defaultAuthEnv(runner: CommandRunner): AuthEnv {
113
+ return {
114
+ env: process.env,
115
+ runner,
116
+ platform: process.platform,
117
+ dir: configDir(),
118
+ };
119
+ }
120
+
121
+ export function isValidKey(value: unknown): value is string {
122
+ return typeof value === "string" && KEY_PATTERN.test(value);
123
+ }
124
+
125
+ /** `vck_…abcd`: never more than a short prefix and the last four characters. */
126
+ export function maskKey(key: string): string {
127
+ if (key.length < 12) return "…";
128
+ if (key.startsWith("sk-or-")) return `sk-or-…${key.slice(-4)}`;
129
+ const underscore = key.indexOf("_");
130
+ const prefix =
131
+ underscore > 0 && underscore <= 4 ? key.slice(0, underscore + 1) : "";
132
+ return `${prefix}…${key.slice(-4)}`;
133
+ }
134
+
135
+ function useKeychain(auth: AuthEnv): boolean {
136
+ return (
137
+ auth.platform === "darwin" &&
138
+ auth.env.DAWG_CREDENTIAL_STORE !== "file" &&
139
+ auth.runner.which("security") !== undefined
140
+ );
141
+ }
142
+
143
+ /** Env, then Keychain, then the credentials file. */
144
+ export async function resolveKey(
145
+ auth: AuthEnv,
146
+ kind: KeyKind,
147
+ ): Promise<ResolvedKey | undefined> {
148
+ const spec = KEY_SPECS[kind];
149
+ const fromEnv = auth.env[spec.env]?.trim();
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];
161
+ if (useKeychain(auth)) {
162
+ const key = (await readKeychain(auth, spec.account)) ?? "";
163
+ if (isValidKey(key)) return { key, source: "keychain" };
164
+ }
165
+ const file = await readJsonObject(join(auth.dir, "credentials.json"));
166
+ const key = file?.[spec.field];
167
+ if (isValidKey(key)) return { key, source: "file" };
168
+ return undefined;
169
+ }
170
+
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(
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,
196
+ key: string,
197
+ ): Promise<"keychain" | "file"> {
198
+ const spec = KEY_SPECS[kind];
199
+ if (!isValidKey(key))
200
+ throw new Error(
201
+ `that does not look like an ${kind === "gateway" ? "AI Gateway" : "OpenRouter"} key`,
202
+ );
203
+ if (useKeychain(auth)) {
204
+ // `security -i` reads the command from stdin, so the secret never appears
205
+ // in any process's argv. KEY_PATTERN guarantees it needs no escaping.
206
+ const result = await auth.runner
207
+ .run("security", ["-i"], {
208
+ stdin: `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${spec.account} -l "${spec.label}" -w "${key}"\n`,
209
+ timeoutMs: 15_000,
210
+ maxOutputBytes: 4096,
211
+ })
212
+ .catch(() => undefined);
213
+ if (result?.code === 0) {
214
+ const check = await readKeychain(auth, spec.account);
215
+ if (check === key) {
216
+ await removeFileKey(auth, kind);
217
+ return "keychain";
218
+ }
219
+ }
220
+ }
221
+ const path = join(auth.dir, "credentials.json");
222
+ const current = (await readJsonObject(path)) ?? {};
223
+ await writePrivateJson(auth.dir, path, {
224
+ ...current,
225
+ version: 1,
226
+ [spec.field]: key,
227
+ });
228
+ return "file";
229
+ }
230
+
231
+ export function storeGatewayKey(
232
+ auth: AuthEnv,
233
+ key: string,
234
+ ): Promise<"keychain" | "file"> {
235
+ return storeKey(auth, "gateway", key);
236
+ }
237
+
238
+ async function readKeychain(
239
+ auth: AuthEnv,
240
+ account: string,
241
+ ): Promise<string | undefined> {
242
+ const result = await auth.runner
243
+ .run(
244
+ "security",
245
+ ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"],
246
+ { timeoutMs: 10_000, maxOutputBytes: 4096 },
247
+ )
248
+ .catch(() => undefined);
249
+ return result?.code === 0 ? result.stdout.trim() : undefined;
250
+ }
251
+
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[]> {
257
+ const removed: string[] = [];
258
+ if (useKeychain(auth)) {
259
+ const result = await auth.runner
260
+ .run(
261
+ "security",
262
+ [
263
+ "delete-generic-password",
264
+ "-s",
265
+ KEYCHAIN_SERVICE,
266
+ "-a",
267
+ KEY_SPECS[kind].account,
268
+ ],
269
+ { timeoutMs: 10_000, maxOutputBytes: 4096 },
270
+ )
271
+ .catch(() => undefined);
272
+ if (result?.code === 0) removed.push("keychain");
273
+ }
274
+ if (await removeFileKey(auth, kind)) removed.push("file");
275
+ return removed;
276
+ }
277
+
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;
289
+ const path = join(auth.dir, "credentials.json");
290
+ const current = await readJsonObject(path);
291
+ if (!current || !(field in current)) return false;
292
+ const { [field]: _removed, ...rest } = current;
293
+ if (Object.keys(rest).filter((key) => key !== "version").length === 0)
294
+ await rm(path, { force: true });
295
+ else await writePrivateJson(auth.dir, path, rest);
296
+ return true;
297
+ }
298
+
299
+ export async function readConfig(
300
+ auth: Pick<AuthEnv, "dir">,
301
+ ): Promise<DawgConfig> {
302
+ return parseConfig(await readJsonObject(join(auth.dir, "config.json")));
303
+ }
304
+
305
+ export function parseConfig(value: unknown): DawgConfig {
306
+ if (typeof value !== "object" || value === null || Array.isArray(value))
307
+ return {};
308
+ const record = value as Record<string, unknown>;
309
+ const config: {
310
+ provider?: ProviderChoice;
311
+ xcb?: XcbPick;
312
+ codex?: XcbPick;
313
+ claude?: XcbPick;
314
+ gatewayModel?: string;
315
+ openrouterModel?: string;
316
+ setup?: "skipped";
317
+ } = {};
318
+ if (PROVIDER_CHOICES.includes(record.provider as ProviderChoice))
319
+ config.provider = record.provider as ProviderChoice;
320
+ for (const slot of SUBSCRIPTION_SLOTS) {
321
+ const pick = parsePick(record[slot]);
322
+ if (pick) config[slot] = pick;
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";
330
+ return config;
331
+ }
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
+
355
+ export async function writeConfig(
356
+ auth: Pick<AuthEnv, "dir">,
357
+ patch: ConfigPatch,
358
+ ): Promise<DawgConfig> {
359
+ const next: Record<string, unknown> = { ...(await readConfig(auth)) };
360
+ for (const [key, value] of Object.entries(patch)) {
361
+ if (value === undefined) delete next[key];
362
+ else next[key] = value;
363
+ }
364
+ const parsed = parseConfig(next);
365
+ await writePrivateJson(auth.dir, join(auth.dir, "config.json"), {
366
+ version: 1,
367
+ ...parsed,
368
+ });
369
+ return parsed;
370
+ }
371
+
372
+ async function readJsonObject(
373
+ path: string,
374
+ ): Promise<Record<string, unknown> | undefined> {
375
+ try {
376
+ const info = await stat(path);
377
+ if (!info.isFile() || info.size > MAX_CONFIG_BYTES) return undefined;
378
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
379
+ return typeof parsed === "object" &&
380
+ parsed !== null &&
381
+ !Array.isArray(parsed)
382
+ ? (parsed as Record<string, unknown>)
383
+ : undefined;
384
+ } catch {
385
+ return undefined;
386
+ }
387
+ }
388
+
389
+ /** Write via a 0600 temp file and rename, under a 0700 directory. */
390
+ export async function writePrivateJson(
391
+ dir: string,
392
+ path: string,
393
+ value: unknown,
394
+ ): Promise<void> {
395
+ await mkdir(dir, { recursive: true, mode: 0o700 });
396
+ await chmod(dir, 0o700);
397
+ const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
398
+ const handle = await open(temp, "wx", 0o600);
399
+ try {
400
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
401
+ await handle.sync();
402
+ } finally {
403
+ await handle.close();
404
+ }
405
+ try {
406
+ await rename(temp, path);
407
+ } catch (error) {
408
+ await rm(temp, { force: true });
409
+ throw error;
410
+ }
411
+ }