@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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isValidKey } from "./credentials.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenRouter's OAuth PKCE flow for apps
|
|
6
|
+
* (https://openrouter.ai/docs/use-cases/oauth-pkce): open
|
|
7
|
+
* `https://openrouter.ai/auth?callback_url=…&code_challenge=…&code_challenge_method=S256`,
|
|
8
|
+
* receive `?code=` on a localhost callback (any port is allowed), then
|
|
9
|
+
* exchange it with `POST /api/v1/auth/keys` for a user-controlled key.
|
|
10
|
+
*/
|
|
11
|
+
export const OPENROUTER_AUTH_URL = "https://openrouter.ai/auth";
|
|
12
|
+
export const OPENROUTER_API_URL = "https://openrouter.ai/api/v1";
|
|
13
|
+
export const OPENROUTER_KEYS_URL = "https://openrouter.ai/settings/keys";
|
|
14
|
+
export const OAUTH_TIMEOUT_MS = 5 * 60_000;
|
|
15
|
+
const MAX_EXCHANGE_BYTES = 16 * 1024;
|
|
16
|
+
const CODE_PATTERN = /^[A-Za-z0-9._~-]{8,512}$/;
|
|
17
|
+
|
|
18
|
+
type Fetcher = (
|
|
19
|
+
input: RequestInfo | URL,
|
|
20
|
+
init?: RequestInit,
|
|
21
|
+
) => Promise<Response>;
|
|
22
|
+
|
|
23
|
+
export type Pkce = Readonly<{
|
|
24
|
+
verifier: string;
|
|
25
|
+
challenge: string;
|
|
26
|
+
state: string;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
function base64url(bytes: Buffer): string {
|
|
30
|
+
return bytes
|
|
31
|
+
.toString("base64")
|
|
32
|
+
.replace(/\+/g, "-")
|
|
33
|
+
.replace(/\//g, "_")
|
|
34
|
+
.replace(/=+$/, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A 43-character verifier, its S256 challenge, and a CSRF state. */
|
|
38
|
+
export function createPkce(
|
|
39
|
+
random: (size: number) => Buffer = randomBytes,
|
|
40
|
+
): Pkce {
|
|
41
|
+
const verifier = base64url(random(32));
|
|
42
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
43
|
+
return { verifier, challenge, state: base64url(random(16)) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function authorizeUrl(
|
|
47
|
+
callbackUrl: string,
|
|
48
|
+
pkce: Pkce,
|
|
49
|
+
base = OPENROUTER_AUTH_URL,
|
|
50
|
+
): string {
|
|
51
|
+
const url = new URL(base);
|
|
52
|
+
url.searchParams.set("callback_url", callbackUrl);
|
|
53
|
+
url.searchParams.set("code_challenge", pkce.challenge);
|
|
54
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
55
|
+
url.searchParams.set("key_label", "dawg");
|
|
56
|
+
url.searchParams.set("state", pkce.state);
|
|
57
|
+
return url.toString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sameSecret(a: string, b: string): boolean {
|
|
61
|
+
const left = Buffer.from(a);
|
|
62
|
+
const right = Buffer.from(b);
|
|
63
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PAGE = (title: string, body: string) =>
|
|
67
|
+
`<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:3rem;max-width:32rem"><h1 style="font-size:1.4rem">${title}</h1><p>${body}</p></body>`;
|
|
68
|
+
|
|
69
|
+
export type CallbackServer = Readonly<{
|
|
70
|
+
/** `http://127.0.0.1:<port>/callback`. */
|
|
71
|
+
url: string;
|
|
72
|
+
/** Resolves with the code once a request carries the right state. */
|
|
73
|
+
code: Promise<string>;
|
|
74
|
+
stop(): void;
|
|
75
|
+
}>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A one-shot callback listener on 127.0.0.1 and an ephemeral port. Requests
|
|
79
|
+
* with a missing or wrong `state` are refused and do not end the wait.
|
|
80
|
+
*/
|
|
81
|
+
export function startCallbackServer(
|
|
82
|
+
state: string,
|
|
83
|
+
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
84
|
+
): CallbackServer {
|
|
85
|
+
let settle: { resolve(code: string): void; reject(error: Error): void };
|
|
86
|
+
const code = new Promise<string>((resolve, reject) => {
|
|
87
|
+
settle = { resolve, reject };
|
|
88
|
+
});
|
|
89
|
+
code.catch(() => undefined);
|
|
90
|
+
const server = Bun.serve({
|
|
91
|
+
hostname: "127.0.0.1",
|
|
92
|
+
port: 0,
|
|
93
|
+
fetch(request) {
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
if (url.pathname !== "/callback")
|
|
96
|
+
return new Response("not found", { status: 404 });
|
|
97
|
+
const html = (status: number, title: string, body: string) =>
|
|
98
|
+
new Response(PAGE(title, body), {
|
|
99
|
+
status,
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": "text/html; charset=utf-8",
|
|
102
|
+
"cache-control": "no-store",
|
|
103
|
+
"referrer-policy": "no-referrer",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const got = url.searchParams.get("state") ?? "";
|
|
107
|
+
const value = url.searchParams.get("code") ?? "";
|
|
108
|
+
if (!sameSecret(got, state))
|
|
109
|
+
return html(
|
|
110
|
+
400,
|
|
111
|
+
"Sign-in not recognized",
|
|
112
|
+
"This link does not match the sign-in dawg started. Return to the terminal and try again.",
|
|
113
|
+
);
|
|
114
|
+
if (!CODE_PATTERN.test(value)) {
|
|
115
|
+
settle.reject(
|
|
116
|
+
new Error("OpenRouter did not return a code (access denied?)"),
|
|
117
|
+
);
|
|
118
|
+
return html(
|
|
119
|
+
400,
|
|
120
|
+
"Sign-in cancelled",
|
|
121
|
+
"No authorization code came back. You can close this tab.",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
settle.resolve(value);
|
|
125
|
+
return html(
|
|
126
|
+
200,
|
|
127
|
+
"dawg is connected to OpenRouter",
|
|
128
|
+
"You can close this tab and return to the terminal.",
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const timer = setTimeout(
|
|
133
|
+
() => settle.reject(new Error("timed out waiting for the browser")),
|
|
134
|
+
options.timeoutMs ?? OAUTH_TIMEOUT_MS,
|
|
135
|
+
);
|
|
136
|
+
const onAbort = () => settle.reject(new Error("sign-in cancelled"));
|
|
137
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
138
|
+
const stop = () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
141
|
+
// Graceful first so the browser still gets the "connected" page, then
|
|
142
|
+
// force-close any idle keep-alive sockets.
|
|
143
|
+
void server.stop(false);
|
|
144
|
+
setTimeout(() => void server.stop(true), 1_000).unref?.();
|
|
145
|
+
};
|
|
146
|
+
void code.then(stop, stop);
|
|
147
|
+
return { url: `http://127.0.0.1:${server.port}/callback`, code, stop };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `POST /api/v1/auth/keys` → the user's new key. */
|
|
151
|
+
export async function exchangeCode(
|
|
152
|
+
code: string,
|
|
153
|
+
pkce: Pick<Pkce, "verifier">,
|
|
154
|
+
options: { fetcher?: Fetcher; baseUrl?: string } = {},
|
|
155
|
+
): Promise<string> {
|
|
156
|
+
const fetcher = options.fetcher ?? fetch;
|
|
157
|
+
const base = (options.baseUrl ?? OPENROUTER_API_URL).replace(/\/$/, "");
|
|
158
|
+
const response = await fetcher(`${base}/auth/keys`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "content-type": "application/json" },
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
code,
|
|
163
|
+
code_verifier: pkce.verifier,
|
|
164
|
+
code_challenge_method: "S256",
|
|
165
|
+
}),
|
|
166
|
+
signal: AbortSignal.timeout(20_000),
|
|
167
|
+
});
|
|
168
|
+
const text = (await response.text()).slice(0, MAX_EXCHANGE_BYTES);
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
throw new Error(
|
|
171
|
+
response.status === 403
|
|
172
|
+
? "OpenRouter refused the code (expired or already used)"
|
|
173
|
+
: `OpenRouter key exchange failed (${response.status})`,
|
|
174
|
+
);
|
|
175
|
+
let key: unknown;
|
|
176
|
+
try {
|
|
177
|
+
key = (JSON.parse(text) as { key?: unknown }).key;
|
|
178
|
+
} catch {
|
|
179
|
+
key = undefined;
|
|
180
|
+
}
|
|
181
|
+
if (!isValidKey(key)) throw new Error("OpenRouter returned no usable key");
|
|
182
|
+
return key;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type KeyCheck = "valid" | "rejected" | "unverified";
|
|
186
|
+
|
|
187
|
+
/** `GET /api/v1/key`: cheap, authenticated, and spends nothing. */
|
|
188
|
+
export async function checkOpenRouterKey(
|
|
189
|
+
key: string,
|
|
190
|
+
options: { fetcher?: Fetcher; baseUrl?: string } = {},
|
|
191
|
+
): Promise<KeyCheck> {
|
|
192
|
+
const fetcher = options.fetcher ?? fetch;
|
|
193
|
+
const base = (options.baseUrl ?? OPENROUTER_API_URL).replace(/\/$/, "");
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetcher(`${base}/key`, {
|
|
196
|
+
headers: { authorization: `Bearer ${key}` },
|
|
197
|
+
signal: AbortSignal.timeout(8_000),
|
|
198
|
+
});
|
|
199
|
+
await response.body?.cancel().catch(() => undefined);
|
|
200
|
+
if (response.ok) return "valid";
|
|
201
|
+
if (response.status === 401 || response.status === 403) return "rejected";
|
|
202
|
+
return "unverified";
|
|
203
|
+
} catch {
|
|
204
|
+
return "unverified";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { stdin, stdout } from "node:process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A small inline picker for the shell (`dawg login`, `dawg model`), in the
|
|
5
|
+
* style of the Codex CLI sign-in: ↑/↓ or j/k move, Enter chooses, 1-9 jump
|
|
6
|
+
* and choose, typing filters (when enabled), Esc or Ctrl-C cancels. It draws
|
|
7
|
+
* below the cursor and erases itself afterwards, so the scrollback keeps only
|
|
8
|
+
* the one-line result the caller prints.
|
|
9
|
+
*/
|
|
10
|
+
export type ChoiceItem = Readonly<{
|
|
11
|
+
label: string;
|
|
12
|
+
/** Right-hand muted text: a status, a price. */
|
|
13
|
+
detail?: string;
|
|
14
|
+
/** Section heading shown above the first row of each group. */
|
|
15
|
+
group?: string;
|
|
16
|
+
/** Rows that cannot be chosen still show (e.g. "not set up"). */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Marked with ● (the current model). */
|
|
19
|
+
current?: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
export type ChooseOptions = Readonly<{
|
|
23
|
+
initial?: number;
|
|
24
|
+
/** Type-to-filter on label and detail. */
|
|
25
|
+
filter?: boolean;
|
|
26
|
+
/** Footer hint override. */
|
|
27
|
+
hint?: string;
|
|
28
|
+
}>;
|
|
29
|
+
|
|
30
|
+
export type PickerKey =
|
|
31
|
+
| { type: "up" }
|
|
32
|
+
| { type: "down" }
|
|
33
|
+
| { type: "enter" }
|
|
34
|
+
| { type: "cancel" }
|
|
35
|
+
| { type: "digit"; value: number }
|
|
36
|
+
| { type: "backspace" }
|
|
37
|
+
| { type: "text"; text: string };
|
|
38
|
+
|
|
39
|
+
/** Split one raw stdin read into picker keys. */
|
|
40
|
+
export function parsePickerKeys(data: string): PickerKey[] {
|
|
41
|
+
const keys: PickerKey[] = [];
|
|
42
|
+
let rest = data.replace(/\u001b\[20[01]~/g, "");
|
|
43
|
+
while (rest.length > 0) {
|
|
44
|
+
if (rest.startsWith("\u001b[A") || rest.startsWith("\u001bOA")) {
|
|
45
|
+
keys.push({ type: "up" });
|
|
46
|
+
rest = rest.slice(3);
|
|
47
|
+
} else if (rest.startsWith("\u001b[B") || rest.startsWith("\u001bOB")) {
|
|
48
|
+
keys.push({ type: "down" });
|
|
49
|
+
rest = rest.slice(3);
|
|
50
|
+
} else if (rest.startsWith("\u001b[")) {
|
|
51
|
+
// Other CSI sequences (left/right, home, …) are ignored.
|
|
52
|
+
const match = /^\u001b\[[0-9;]*[A-Za-z~]/.exec(rest);
|
|
53
|
+
rest = rest.slice(match ? match[0].length : 2);
|
|
54
|
+
} else {
|
|
55
|
+
const char = rest[0]!;
|
|
56
|
+
rest = rest.slice(1);
|
|
57
|
+
if (char === "\r" || char === "\n") keys.push({ type: "enter" });
|
|
58
|
+
else if (char === "\u001b" || char === "\u0003")
|
|
59
|
+
keys.push({ type: "cancel" });
|
|
60
|
+
else if (char === "\u007f" || char === "\b")
|
|
61
|
+
keys.push({ type: "backspace" });
|
|
62
|
+
else if (char === "\u0010") keys.push({ type: "up" });
|
|
63
|
+
else if (char === "\u000e") keys.push({ type: "down" });
|
|
64
|
+
else if (char >= " ") keys.push({ type: "text", text: char });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return keys;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The picker's pure state; `step` applies one key. */
|
|
71
|
+
export class PickerModel {
|
|
72
|
+
index: number;
|
|
73
|
+
query = "";
|
|
74
|
+
constructor(
|
|
75
|
+
readonly items: readonly ChoiceItem[],
|
|
76
|
+
readonly options: ChooseOptions = {},
|
|
77
|
+
) {
|
|
78
|
+
const visible = this.visible();
|
|
79
|
+
const initial = options.initial ?? 0;
|
|
80
|
+
const start = visible.indexOf(initial) >= 0 ? initial : (visible[0] ?? 0);
|
|
81
|
+
this.index = start;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Indices of rows matching the filter, in order. */
|
|
85
|
+
visible(): number[] {
|
|
86
|
+
const query = this.query.toLowerCase();
|
|
87
|
+
return this.items
|
|
88
|
+
.map((item, index) => ({ item, index }))
|
|
89
|
+
.filter(
|
|
90
|
+
({ item }) =>
|
|
91
|
+
!query ||
|
|
92
|
+
item.label.toLowerCase().includes(query) ||
|
|
93
|
+
(item.detail ?? "").toLowerCase().includes(query) ||
|
|
94
|
+
(item.group ?? "").toLowerCase().includes(query),
|
|
95
|
+
)
|
|
96
|
+
.map(({ index }) => index);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `undefined` while picking, a row index when chosen, `null` on cancel. */
|
|
100
|
+
step(key: PickerKey): number | null | undefined {
|
|
101
|
+
const visible = this.visible();
|
|
102
|
+
const position = Math.max(0, visible.indexOf(this.index));
|
|
103
|
+
const move = (delta: number) => {
|
|
104
|
+
if (visible.length === 0) return;
|
|
105
|
+
for (let offset = 1; offset <= visible.length; offset += 1) {
|
|
106
|
+
const n = visible.length;
|
|
107
|
+
const next = visible[(((position + delta * offset) % n) + n) % n]!;
|
|
108
|
+
if (!this.items[next]?.disabled) {
|
|
109
|
+
this.index = next;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
switch (key.type) {
|
|
115
|
+
case "up":
|
|
116
|
+
move(-1);
|
|
117
|
+
return undefined;
|
|
118
|
+
case "down":
|
|
119
|
+
move(1);
|
|
120
|
+
return undefined;
|
|
121
|
+
case "enter":
|
|
122
|
+
return visible.includes(this.index) && !this.items[this.index]?.disabled
|
|
123
|
+
? this.index
|
|
124
|
+
: undefined;
|
|
125
|
+
case "cancel":
|
|
126
|
+
if (this.query) {
|
|
127
|
+
this.query = "";
|
|
128
|
+
this.refocus();
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
case "backspace":
|
|
133
|
+
this.query = this.query.slice(0, -1);
|
|
134
|
+
this.refocus();
|
|
135
|
+
return undefined;
|
|
136
|
+
case "digit": {
|
|
137
|
+
const target = visible[key.value - 1];
|
|
138
|
+
if (target !== undefined && !this.items[target]?.disabled)
|
|
139
|
+
return target;
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
case "text":
|
|
143
|
+
if (/^[1-9]$/.test(key.text) && !this.query)
|
|
144
|
+
return this.step({ type: "digit", value: Number(key.text) });
|
|
145
|
+
if (!this.options.filter) {
|
|
146
|
+
if (key.text === "k") move(-1);
|
|
147
|
+
if (key.text === "j") move(1);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (this.query.length < 40) this.query += key.text;
|
|
151
|
+
this.refocus();
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private refocus(): void {
|
|
157
|
+
const visible = this.visible().filter(
|
|
158
|
+
(index) => !this.items[index]?.disabled,
|
|
159
|
+
);
|
|
160
|
+
if (!visible.includes(this.index) && visible.length > 0)
|
|
161
|
+
this.index = visible[0]!;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Plain lines (no colour); the renderer styles them. */
|
|
165
|
+
lines(
|
|
166
|
+
title: string,
|
|
167
|
+
maxRows = 14,
|
|
168
|
+
): {
|
|
169
|
+
text: string;
|
|
170
|
+
kind: "title" | "group" | "row" | "selected" | "muted" | "hint";
|
|
171
|
+
}[] {
|
|
172
|
+
const out: {
|
|
173
|
+
text: string;
|
|
174
|
+
kind: "title" | "group" | "row" | "selected" | "muted" | "hint";
|
|
175
|
+
}[] = [
|
|
176
|
+
{
|
|
177
|
+
text: title + (this.query ? ` filter: ${this.query}` : ""),
|
|
178
|
+
kind: "title",
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
const visible = this.visible();
|
|
182
|
+
if (visible.length === 0) out.push({ text: " no matches", kind: "muted" });
|
|
183
|
+
const position = Math.max(0, visible.indexOf(this.index));
|
|
184
|
+
const first = Math.max(
|
|
185
|
+
0,
|
|
186
|
+
Math.min(visible.length - maxRows, position - Math.floor(maxRows / 2)),
|
|
187
|
+
);
|
|
188
|
+
let lastGroup: string | undefined;
|
|
189
|
+
visible.slice(first, first + maxRows).forEach((index, offset) => {
|
|
190
|
+
const item = this.items[index]!;
|
|
191
|
+
if (item.group && item.group !== lastGroup) {
|
|
192
|
+
out.push({ text: ` ${item.group}`, kind: "group" });
|
|
193
|
+
}
|
|
194
|
+
lastGroup = item.group;
|
|
195
|
+
const selected = index === this.index;
|
|
196
|
+
const number =
|
|
197
|
+
first + offset < 9 && !this.query ? `${first + offset + 1}.` : " ";
|
|
198
|
+
const mark = item.current ? "●" : " ";
|
|
199
|
+
out.push({
|
|
200
|
+
text: `${selected ? "›" : " "} ${number} ${mark} ${item.label}${item.detail ? ` ${item.detail}` : ""}`,
|
|
201
|
+
kind: selected ? "selected" : item.disabled ? "muted" : "row",
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
out.push({
|
|
205
|
+
text:
|
|
206
|
+
this.options.hint ??
|
|
207
|
+
` ↑/↓ move · enter choose · 1-9 pick${this.options.filter ? " · type to filter" : ""} · esc cancel`,
|
|
208
|
+
kind: "hint",
|
|
209
|
+
});
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const ESC = "\u001b[";
|
|
215
|
+
|
|
216
|
+
function style(kind: string, text: string, color: boolean): string {
|
|
217
|
+
if (!color) return text;
|
|
218
|
+
if (kind === "title") return `${ESC}1m${text}${ESC}0m`;
|
|
219
|
+
if (kind === "selected") return `${ESC}1;36m${text}${ESC}0m`;
|
|
220
|
+
if (kind === "group") return `${ESC}2;4m${text}${ESC}0m`;
|
|
221
|
+
if (kind === "muted" || kind === "hint") return `${ESC}2m${text}${ESC}0m`;
|
|
222
|
+
return text;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run a picker on the real terminal. Resolves the chosen index, or
|
|
227
|
+
* `undefined` on Esc / Ctrl-C. Requires a TTY on stdin and stdout.
|
|
228
|
+
*/
|
|
229
|
+
export function chooseOnTerminal(
|
|
230
|
+
title: string,
|
|
231
|
+
items: readonly ChoiceItem[],
|
|
232
|
+
options: ChooseOptions = {},
|
|
233
|
+
): Promise<number | undefined> {
|
|
234
|
+
const model = new PickerModel(items, options);
|
|
235
|
+
const color = !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
236
|
+
const width = () => Math.max(20, (stdout.columns ?? 80) - 1);
|
|
237
|
+
let drawn = 0;
|
|
238
|
+
const draw = () => {
|
|
239
|
+
const lines = model.lines(
|
|
240
|
+
title,
|
|
241
|
+
Math.max(4, Math.min(14, (stdout.rows ?? 24) - 6)),
|
|
242
|
+
);
|
|
243
|
+
let out = drawn > 0 ? `\r${ESC}${drawn - 1}A` : "\r";
|
|
244
|
+
out += `${ESC}J`;
|
|
245
|
+
out += lines
|
|
246
|
+
.map((line) => style(line.kind, truncate(line.text, width()), color))
|
|
247
|
+
.join("\r\n");
|
|
248
|
+
drawn = lines.length;
|
|
249
|
+
stdout.write(`${ESC}?25l${out}`);
|
|
250
|
+
};
|
|
251
|
+
const erase = () => {
|
|
252
|
+
if (drawn > 0) stdout.write(`\r${ESC}${drawn - 1}A${ESC}J`);
|
|
253
|
+
stdout.write(`${ESC}?25h`);
|
|
254
|
+
};
|
|
255
|
+
return new Promise((resolve) => {
|
|
256
|
+
const wasRaw = stdin.isRaw;
|
|
257
|
+
stdin.setRawMode?.(true);
|
|
258
|
+
stdin.resume();
|
|
259
|
+
const finish = (result: number | undefined) => {
|
|
260
|
+
stdin.off("data", onData);
|
|
261
|
+
stdin.setRawMode?.(wasRaw);
|
|
262
|
+
stdin.pause();
|
|
263
|
+
erase();
|
|
264
|
+
resolve(result);
|
|
265
|
+
};
|
|
266
|
+
const onData = (chunk: Buffer) => {
|
|
267
|
+
for (const key of parsePickerKeys(chunk.toString("utf8"))) {
|
|
268
|
+
const result = model.step(key);
|
|
269
|
+
if (result === null) return finish(undefined);
|
|
270
|
+
if (result !== undefined) return finish(result);
|
|
271
|
+
}
|
|
272
|
+
draw();
|
|
273
|
+
};
|
|
274
|
+
stdin.on("data", onData);
|
|
275
|
+
draw();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function truncate(text: string, width: number): string {
|
|
280
|
+
const chars = [...text];
|
|
281
|
+
return chars.length > width ? `${chars.slice(0, width - 1).join("")}…` : text;
|
|
282
|
+
}
|
package/src/auth/runner.ts
CHANGED
|
@@ -14,6 +14,11 @@ export type RunOptions = Readonly<{
|
|
|
14
14
|
/** Bound on captured stdout/stderr; excess is dropped. */
|
|
15
15
|
maxOutputBytes?: number;
|
|
16
16
|
env?: Readonly<Record<string, string | undefined>>;
|
|
17
|
+
/**
|
|
18
|
+
* Called with each decoded chunk as it arrives (progress parsing). Chunks
|
|
19
|
+
* keep flowing past `maxOutputBytes`; only the captured text is bounded.
|
|
20
|
+
*/
|
|
21
|
+
onOutput?: (stream: "stdout" | "stderr", chunk: string) => void;
|
|
17
22
|
}>;
|
|
18
23
|
|
|
19
24
|
export type RunResult = Readonly<{
|
|
@@ -73,9 +78,18 @@ export const systemRunner: CommandRunner = {
|
|
|
73
78
|
// The child may exit before reading its input; its exit code reports why.
|
|
74
79
|
}
|
|
75
80
|
}
|
|
81
|
+
const tap = options.onOutput;
|
|
76
82
|
const [stdout, stderr, code] = await Promise.all([
|
|
77
|
-
readBounded(
|
|
78
|
-
|
|
83
|
+
readBounded(
|
|
84
|
+
child.stdout,
|
|
85
|
+
maxBytes,
|
|
86
|
+
tap && ((chunk) => tap("stdout", chunk)),
|
|
87
|
+
),
|
|
88
|
+
readBounded(
|
|
89
|
+
child.stderr,
|
|
90
|
+
maxBytes,
|
|
91
|
+
tap && ((chunk) => tap("stderr", chunk)),
|
|
92
|
+
),
|
|
79
93
|
child.exited,
|
|
80
94
|
]);
|
|
81
95
|
return { code, stdout, stderr, killed: stop.killed() };
|
|
@@ -127,12 +141,21 @@ function watchKill(
|
|
|
127
141
|
async function readBounded(
|
|
128
142
|
stream: ReadableStream<Uint8Array> | undefined | null,
|
|
129
143
|
maxBytes: number,
|
|
144
|
+
tap?: (chunk: string) => void,
|
|
130
145
|
): Promise<string> {
|
|
131
146
|
if (!stream) return "";
|
|
132
147
|
const decoder = new TextDecoder();
|
|
148
|
+
const tapDecoder = tap ? new TextDecoder() : undefined;
|
|
133
149
|
let text = "";
|
|
134
150
|
let bytes = 0;
|
|
135
151
|
for await (const chunk of stream) {
|
|
152
|
+
if (tap && tapDecoder) {
|
|
153
|
+
try {
|
|
154
|
+
tap(tapDecoder.decode(chunk, { stream: true }));
|
|
155
|
+
} catch {
|
|
156
|
+
// A progress parser must never stop the drain.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
136
159
|
if (bytes >= maxBytes) continue; // Keep draining so the child never blocks.
|
|
137
160
|
const room = maxBytes - bytes;
|
|
138
161
|
const part = chunk.byteLength > room ? chunk.subarray(0, room) : chunk;
|
package/src/auth/tui.ts
CHANGED
|
@@ -2,23 +2,28 @@ import { hostname } from "node:os";
|
|
|
2
2
|
import { defaultAuthEnv } from "./credentials.ts";
|
|
3
3
|
import {
|
|
4
4
|
authStatus,
|
|
5
|
-
|
|
6
|
-
login,
|
|
5
|
+
isLogoutTarget,
|
|
7
6
|
logout,
|
|
7
|
+
modelChoices,
|
|
8
|
+
saveModelChoice,
|
|
8
9
|
type LoginIO,
|
|
9
10
|
} from "./login.ts";
|
|
10
11
|
import { systemRunner, type CommandRunner } from "./runner.ts";
|
|
11
|
-
import type { GatewayModel } from "../agent/gateway.ts";
|
|
12
12
|
import { audioStatusLine } from "../audio/engine.ts";
|
|
13
|
+
import {
|
|
14
|
+
providerLabel,
|
|
15
|
+
selectProvider,
|
|
16
|
+
type ProviderSelection,
|
|
17
|
+
} from "../agent/provider.ts";
|
|
18
|
+
import { parseLoginArgs } from "./cli.ts";
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
|
-
* `/
|
|
16
|
-
*
|
|
17
|
-
*
|
|
21
|
+
* `/logout [provider]` and `/auth [--check]` inside the TUI: they print only,
|
|
22
|
+
* so they run without leaving the screen and return their lines as cards.
|
|
23
|
+
* `/login` hands the terminal to the shell flow instead (see main.ts).
|
|
18
24
|
*/
|
|
19
25
|
export async function tuiAuthCommand(
|
|
20
26
|
command: string,
|
|
21
|
-
gatewayModel: GatewayModel,
|
|
22
27
|
runner: CommandRunner = systemRunner,
|
|
23
28
|
): Promise<string[]> {
|
|
24
29
|
const lines: string[] = [];
|
|
@@ -31,60 +36,72 @@ export async function tuiAuthCommand(
|
|
|
31
36
|
const deps = { auth: defaultAuthEnv(runner), io, hostname: hostname() };
|
|
32
37
|
const [name, ...rest] = command.trim().split(/\s+/);
|
|
33
38
|
try {
|
|
34
|
-
if (/^\/logout$/i.test(name ?? ""))
|
|
35
|
-
|
|
39
|
+
if (/^\/logout$/i.test(name ?? "")) {
|
|
40
|
+
const target = rest[0];
|
|
41
|
+
if (target !== undefined && !isLogoutTarget(target))
|
|
42
|
+
lines.push("usage · /logout [gateway|openrouter|codex|claude|xcb]");
|
|
43
|
+
else await logout(deps, target);
|
|
44
|
+
} else if (/^\/auth$/i.test(name ?? ""))
|
|
36
45
|
lines.push(
|
|
37
46
|
...(await authStatus(deps, {
|
|
38
47
|
verify: rest.includes("--check"),
|
|
39
|
-
gatewayModel,
|
|
40
48
|
})),
|
|
41
49
|
audioStatusLine(),
|
|
42
50
|
);
|
|
43
|
-
else
|
|
44
|
-
const account = flagValue(rest, "--account");
|
|
45
|
-
const model = flagValue(rest, "--model");
|
|
46
|
-
await login(
|
|
47
|
-
"xcb",
|
|
48
|
-
deps,
|
|
49
|
-
account && model ? { xcb: { account, model } } : {},
|
|
50
|
-
);
|
|
51
|
-
} else if (rest.includes("--key"))
|
|
52
|
-
lines.push(
|
|
53
|
-
"Paste a key from a shell: `dawg login --key` (input is hidden there).",
|
|
54
|
-
);
|
|
55
|
-
else await login("gateway", deps);
|
|
51
|
+
else lines.push(`unknown auth command: ${(name ?? "").slice(0, 20)}`);
|
|
56
52
|
} catch (error) {
|
|
57
53
|
lines.push(
|
|
58
|
-
`auth failed
|
|
54
|
+
`auth failed · ${error instanceof Error ? error.message : String(error)} · dawg login in a shell`,
|
|
59
55
|
);
|
|
60
56
|
}
|
|
61
57
|
return lines.map((line) => line.slice(0, 240));
|
|
62
58
|
}
|
|
63
59
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return value && !value.startsWith("--") ? value : undefined;
|
|
60
|
+
/** Validate `/login …` arguments before suspending the screen. */
|
|
61
|
+
export function tuiLoginArgs(command: string) {
|
|
62
|
+
return parseLoginArgs(command.trim().split(/\s+/).slice(1));
|
|
68
63
|
}
|
|
69
64
|
|
|
70
|
-
export type
|
|
65
|
+
export type ModelPickerItem = Readonly<{
|
|
66
|
+
label: string;
|
|
67
|
+
value: string;
|
|
68
|
+
detail: string;
|
|
69
|
+
current: boolean;
|
|
70
|
+
}>;
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* unreadable (the plain login path then prints the guidance).
|
|
73
|
+
* `/model` picker rows for the active provider, valued as the follow-up
|
|
74
|
+
* `/model <id>` command that saves the choice.
|
|
76
75
|
*/
|
|
77
|
-
export async function
|
|
76
|
+
export async function modelPickerItems(
|
|
77
|
+
selection: ProviderSelection,
|
|
78
78
|
runner: CommandRunner = systemRunner,
|
|
79
|
-
): Promise<
|
|
80
|
-
const
|
|
81
|
-
return
|
|
82
|
-
label:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
...(account.admission === "pending" ? ["admits on first use"] : []),
|
|
87
|
-
].join(" · "),
|
|
88
|
-
value: `/login --xcb --account ${account.id} --model ${model.key}`,
|
|
79
|
+
): Promise<ModelPickerItem[]> {
|
|
80
|
+
const rows = await modelChoices({ auth: defaultAuthEnv(runner) }, selection);
|
|
81
|
+
return rows.map((row) => ({
|
|
82
|
+
label: row.label,
|
|
83
|
+
detail: `${row.group} · ${row.detail}`,
|
|
84
|
+
current: row.current,
|
|
85
|
+
value: `/model ${row.value}`,
|
|
89
86
|
}));
|
|
90
87
|
}
|
|
88
|
+
|
|
89
|
+
/** `/model <value>`: save for the active provider; returns the receipt. */
|
|
90
|
+
export async function tuiSetModel(
|
|
91
|
+
value: string,
|
|
92
|
+
runner: CommandRunner = systemRunner,
|
|
93
|
+
): Promise<string> {
|
|
94
|
+
const auth = defaultAuthEnv(runner);
|
|
95
|
+
const selection = await selectProvider(auth);
|
|
96
|
+
if (selection.kind === "offline")
|
|
97
|
+
return `model · unavailable: ${selection.reason}`;
|
|
98
|
+
try {
|
|
99
|
+
return `model · ${await saveModelChoice(auth, selection, value)}`;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const rows = await modelPickerItems(selection, runner).catch(() => []);
|
|
102
|
+
return `model · ${error instanceof Error ? error.message : String(error)}; try ${rows
|
|
103
|
+
.slice(0, 8)
|
|
104
|
+
.map((row) => row.label)
|
|
105
|
+
.join(", ")} · current ${providerLabel(selection)}`;
|
|
106
|
+
}
|
|
107
|
+
}
|