solrouter 0.2.3 → 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.
package/bin/proxy.js CHANGED
@@ -15,9 +15,33 @@ import { rmSync, existsSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
 
17
17
  const [, , cmdRaw, ...rest] = process.argv;
18
- const cmd = cmdRaw || "start";
18
+ const cmd = cmdRaw || "run";
19
19
  const flag = name => { const i = rest.indexOf(name); return i >= 0 ? (rest[i + 1] ?? true) : undefined; };
20
20
 
21
+ // Start the proxy in-process, then launch the chosen tool through it (Claude Code),
22
+ // or — for GUI tools — keep the proxy up and print how to point the tool at it.
23
+ async function startProxyAndLaunch(cfg, tool, launch, passThru = []) {
24
+ const { spawn } = await import("node:child_process");
25
+ const base = `http://127.0.0.1:${cfg.port}`;
26
+ startServer(cfg);
27
+ if ((tool === "claude" || tool === "code") && launch !== false) {
28
+ console.log(`\nLaunching Claude Code through the proxy…\n`);
29
+ const child = spawn("claude", passThru, {
30
+ stdio: "inherit",
31
+ env: { ...process.env, ANTHROPIC_BASE_URL: base, OPENAI_BASE_URL: base + "/v1" },
32
+ });
33
+ child.on("error", e => { console.error(e.code === "ENOENT" ? "Couldn't find `claude` on your PATH." : e.message); process.exit(1); });
34
+ child.on("exit", c => process.exit(c ?? 0));
35
+ return;
36
+ }
37
+ // GUI / other tools: proxy stays running; tell them where to point it.
38
+ console.log("\nProxy is running. Point your tool here:");
39
+ if (tool === "cursor") console.log(` Cursor → Settings → Models → OpenAI Base URL: ${base}/v1 (+ your API key)`);
40
+ else if (tool === "cline") console.log(` Cline → API Provider → OpenAI Compatible → Base URL: ${base}/v1`);
41
+ else { console.log(` OpenAI-style: ${base}/v1`); console.log(` Anthropic-style: ${base}`); }
42
+ console.log("");
43
+ }
44
+
21
45
  async function main() {
22
46
  switch (cmd) {
23
47
  case "login": {
@@ -85,6 +109,8 @@ async function main() {
85
109
  case "claude": {
86
110
  const cfg = loadConfig({ port: flag("--port") ? Number(flag("--port")) : undefined,
87
111
  mode: flag("--mode") || undefined });
112
+ const { banner } = await import("../src/banner.js");
113
+ console.log(banner({ menu: false }));
88
114
  const { spawn } = await import("node:child_process");
89
115
  startServer(cfg); // non-blocking listener in this process
90
116
  // Pass everything after `code`/`claude` (minus our own --port/--mode) to Claude Code.
@@ -108,12 +134,39 @@ async function main() {
108
134
  return;
109
135
  }
110
136
 
111
- case "start":
137
+ case "setup": {
138
+ const { runSetup } = await import("../src/setup.js");
139
+ let res;
140
+ try { res = await runSetup(); }
141
+ catch (e) { console.log(e.message === "cancelled" ? "Setup cancelled." : "Setup: " + e.message); return; }
142
+ const cfg = loadConfig();
143
+ if (res.launch) return startProxyAndLaunch(cfg, res.tool, true);
144
+ console.log(paintDone(cfg, res.tool));
145
+ return;
146
+ }
147
+
148
+ case "run": // bare `solrouter`: first run → setup; after that → launch
112
149
  default: {
150
+ let cfg = loadConfig();
151
+ if (!cfg.setupComplete && process.stdin.isTTY) {
152
+ const { runSetup } = await import("../src/setup.js");
153
+ let res;
154
+ try { res = await runSetup(); }
155
+ catch (e) { console.log(e.message === "cancelled" ? "\nSetup cancelled — run `solrouter setup` any time." : "Setup: " + e.message); return; }
156
+ cfg = loadConfig();
157
+ if (res.launch) return startProxyAndLaunch(cfg, res.tool, true);
158
+ console.log(paintDone(cfg, res.tool));
159
+ return;
160
+ }
161
+ // already set up → launch the saved tool through the proxy
162
+ return startProxyAndLaunch(cfg, cfg.tool || "claude", true);
163
+ }
164
+
165
+ case "start": { // just the proxy, no tool launch
113
166
  const cfg = loadConfig({ port: flag("--port") ? Number(flag("--port")) : undefined,
114
167
  mode: flag("--mode") || undefined });
115
- // Pass-through supplies frontier creds per request, so login is not required
116
- // to start — it only unlocks the private/uncensored leg.
168
+ const { banner } = await import("../src/banner.js");
169
+ console.log(banner());
117
170
  if (!isLoggedIn()) {
118
171
  console.warn("Note: not logged in — the private/uncensored leg is unavailable.");
119
172
  console.warn(" The frontier leg uses your tool's own credentials. Run `login` to enable both.\n");
@@ -124,4 +177,10 @@ async function main() {
124
177
  }
125
178
  }
126
179
 
180
+ function paintDone(cfg, tool) {
181
+ const base = `http://127.0.0.1:${cfg.port}`;
182
+ return `\nYou're set up. Run solrouter to launch ${tool === "claude" ? "Claude Code" : tool} through the proxy.\n` +
183
+ (tool && tool !== "claude" ? `Or point ${tool} at ${base}/v1 manually.\n` : "");
184
+ }
185
+
127
186
  main().catch(e => { console.error(e); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solrouter",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Local privacy router for coding tools: pseudonymises every request before it leaves your machine, and sends work a frontier model would refuse to an uncensored open-weight model you control.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/banner.js CHANGED
@@ -1,18 +1,23 @@
1
- // banner.js — the terminal splash, rendered as real pixels using truecolor
2
- // half-block characters (▀). Each character cell packs two vertical pixels (top =
3
- // foreground colour, bottom = background), so the art is twice as tall as the grid.
4
- // Transparent cells fall through to the terminal's own background.
5
- //
6
- // The cat wears a hoodie with the wave mark rendered in pixels. On image-capable
7
- // terminals we could show the literal PNG instead; this is the universal fallback.
1
+ // banner.js — pixel art for the terminal, rendered with truecolor half-blocks (▀).
2
+ // Exports the cat (wearing the real wave mark on its hoodie) and the mark alone,
3
+ // plus helpers the interactive setup uses to compose the full-width screen.
8
4
 
9
- const C = {
10
- bg: "#0a0908", cream: "#F5E6D3", out: "#050403", eye: "#0a0908",
11
- nose: "#c98f7f", hood: "#15130f", accent: "#F5E6D3", muted: "#b5ac9f", faint: "#78746b",
5
+ import { MARK_LG, MARK_SM } from "./mark.js";
6
+
7
+ export const PAL = {
8
+ bg: "#0a0908", raised: "#161412", accent: "#F5E6D3", ink: "#e8e0d4",
9
+ muted: "#b5ac9f", faint: "#78746b", rule: "#2a2723",
10
+ cream: "#F5E6D3", out: "#050403", eye: "#0a0908", nose: "#c98f7f", hood: "#15130f",
12
11
  };
13
12
 
14
- // 26-wide sprite. .=transparent o=outline b=body e=eye n=nose p=ear-pink H=hoodie v=logo-wave
15
- const GRID = [
13
+ const rgb = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
14
+ export const fg = (h) => { const [r, g, b] = rgb(h); return `\x1b[38;2;${r};${g};${b}m`; };
15
+ export const bgc = (h) => { const [r, g, b] = rgb(h); return `\x1b[48;2;${r};${g};${b}m`; };
16
+ export const RESET = "\x1b[0m";
17
+ export const paint = (h, t, bold = false) => fg(h) + (bold ? "\x1b[1m" : "") + t + RESET;
18
+
19
+ // Base cat, clean hoodie. .=transparent o=outline b=body e=eye n=nose p=ear H=hoodie
20
+ const CAT = [
16
21
  "...oo................oo....",
17
22
  "..obbo..............obbo..",
18
23
  "..obpbo............obpbo..",
@@ -29,34 +34,41 @@ const GRID = [
29
34
  "....obbbbbbbbbbbbbbbbo....",
30
35
  "...oHHHHHHHHHHHHHHHHHHo...",
31
36
  "..oHHHHHHHHHHHHHHHHHHHHo..",
32
- "..oHHHvvHHvvHHvvHHvvHHHo..",
33
- "..oHHvHHvvHHvvHHvvHHvHHo..",
34
37
  "..oHHHHHHHHHHHHHHHHHHHHo..",
35
- "..oHHHvvHHvvHHvvHHvvHHHo..",
36
- "..oHHvHHvvHHvvHHvvHHvHHo..",
38
+ "..oHHHHHHHHHHHHHHHHHHHHo..",
39
+ "..oHHHHHHHHHHHHHHHHHHHHo..",
40
+ "..oHHHHHHHHHHHHHHHHHHHHo..",
41
+ "..oHHHHHHHHHHHHHHHHHHHHo..",
37
42
  "..obbHHHHHHHHHHHHHHHHbbo..",
38
43
  "..obbboHHHHHHHHHHHHobbbo..",
39
44
  "...oooo.oooooooooo.oooo...",
40
45
  ];
41
- const CMAP = { o: C.out, b: C.cream, e: C.eye, n: C.nose, p: C.nose, H: C.hood, v: C.cream };
42
46
 
43
- const rgb = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
44
- const fg = (h) => { const [r, g, b] = rgb(h); return `\x1b[38;2;${r};${g};${b}m`; };
45
- const bgc = (h) => { const [r, g, b] = rgb(h); return `\x1b[48;2;${r};${g};${b}m`; };
46
- const RESET = "\x1b[0m";
47
- const DIM = (h, t) => fg(h) + t + RESET;
47
+ // Overlay the real mark (SM) onto the hoodie, centred.
48
+ function catWithMark() {
49
+ const g = CAT.map(r => r.split(""));
50
+ const mw = MARK_SM[0].length, mh = MARK_SM.length;
51
+ const c0 = Math.round((26 - mw) / 2); // centre horizontally
52
+ const r0 = 15; // top of hoodie chest
53
+ for (let y = 0; y < mh; y++) for (let x = 0; x < mw; x++) {
54
+ if (MARK_SM[y][x] === "v" && g[r0 + y] && g[r0 + y][c0 + x] === "H") g[r0 + y][c0 + x] = "v";
55
+ }
56
+ return g.map(r => r.join(""));
57
+ }
48
58
 
49
- /** Render the sprite to an array of ANSI lines (indented `pad` spaces). */
50
- function sprite(pad = 0) {
59
+ const CMAP = { o: PAL.out, b: PAL.cream, e: PAL.eye, n: PAL.nose, p: PAL.nose, H: PAL.hood, v: PAL.cream };
60
+
61
+ /** Render a pixel grid to ANSI half-block lines. Display width = grid width in chars. */
62
+ export function renderGrid(grid, cmap = CMAP) {
51
63
  const lines = [];
52
- for (let y = 0; y < GRID.length; y += 2) {
53
- let line = " ".repeat(pad);
54
- for (let x = 0; x < GRID[y].length; x++) {
55
- const top = CMAP[GRID[y][x]];
56
- const bot = GRID[y + 1] ? CMAP[GRID[y + 1][x]] : undefined;
64
+ for (let y = 0; y < grid.length; y += 2) {
65
+ let line = "";
66
+ for (let x = 0; x < grid[y].length; x++) {
67
+ const top = cmap[grid[y][x]];
68
+ const bot = grid[y + 1] ? cmap[grid[y + 1][x]] : undefined;
57
69
  if (top && bot) line += fg(top) + bgc(bot) + "▀" + RESET;
58
- else if (top) line += fg(top) + "▀" + RESET; // bottom transparent
59
- else if (bot) line += fg(bot) + "▄" + RESET; // top transparent
70
+ else if (top) line += fg(top) + "▀" + RESET;
71
+ else if (bot) line += fg(bot) + "▄" + RESET;
60
72
  else line += " ";
61
73
  }
62
74
  lines.push(line);
@@ -64,25 +76,16 @@ function sprite(pad = 0) {
64
76
  return lines;
65
77
  }
66
78
 
67
- /** The full splash string. */
68
- export function banner({ menu = true } = {}) {
69
- const art = sprite(4);
70
- const L = [];
71
- L.push("");
72
- L.push(" " + fg(C.accent) + "≈≈" + RESET + " " + fg(C.accent) + "\x1b[1mSOLROUTER" + RESET);
73
- L.push(" " + DIM(C.muted, "private routing for your coding agent"));
74
- L.push("");
75
- art.forEach(a => L.push(a));
76
- L.push(" " + DIM(C.faint, "the router, in the hoodie"));
77
- if (menu) {
78
- L.push("");
79
- L.push(" " + DIM(C.cream, "How should the frontier model be paid?"));
80
- L.push(" " + fg(C.accent) + "▸ SolRouter subscription" + RESET + DIM(C.faint, " — just sign in"));
81
- L.push(" " + DIM(C.faint, " My own API key — paste once"));
82
- L.push(" " + DIM(C.faint, " ↑↓ move ↵ select esc quit"));
83
- }
84
- L.push("");
85
- return L.join("\n");
79
+ export const catLines = () => renderGrid(catWithMark());
80
+ export const catWidth = () => CAT[0].length;
81
+ export const markLines = () => renderGrid(MARK_LG, { v: PAL.cream });
82
+
83
+ /** Legacy one-shot splash (used by the `banner` command). */
84
+ export function banner() {
85
+ const L = ["", " " + paint(PAL.accent, "≈≈", true) + " " + paint(PAL.accent, "SOLROUTER", true),
86
+ " " + paint(PAL.muted, "private routing for your coding agent"), ""];
87
+ catLines().forEach(l => L.push(" " + l));
88
+ L.push(""); return L.join("\n");
86
89
  }
87
90
 
88
- export default { banner };
91
+ export default { banner, catLines, markLines, catWidth, renderGrid, PAL, paint, fg, bgc, RESET };
package/src/mark.js ADDED
@@ -0,0 +1,36 @@
1
+ // mark.js — the SolRouter wave mark as pixels, decoded from the real
2
+ // Solrouter-logo-dark.png (not hand-drawn), so the terminal shows the actual logo.
3
+ // 'v' = wave (cream), '.' = transparent. LG for the header, SM for the hoodie patch.
4
+
5
+ export const MARK_LG = [
6
+ ".....vvvv........vvvv.....",
7
+ "....vvvvvv......vvvvvvv...",
8
+ "..vvv....vvv..vvvv...vvv..",
9
+ "vvvv......vvvvvv......vvvv",
10
+ "vv.........vvvv.........vv",
11
+ ".........................",
12
+ ".....vvvv........vvvv.....",
13
+ "....vvvvvv......vvvvvvv...",
14
+ "..vvvv...vvv...vvv...vvv..",
15
+ "vvvv......vvvvvv......vvvv",
16
+ "vvv........vvvv........vvv",
17
+ ];
18
+
19
+ // Nearest-neighbour downsample to a target width/height for the small hoodie patch.
20
+ function downsample(grid, tw, th) {
21
+ const h = grid.length, w = grid[0].length, out = [];
22
+ for (let y = 0; y < th; y++) {
23
+ let row = "";
24
+ for (let x = 0; x < tw; x++) {
25
+ const sy = Math.min(h - 1, Math.round(y * (h - 1) / (th - 1)));
26
+ const sx = Math.min(w - 1, Math.round(x * (w - 1) / (tw - 1)));
27
+ row += grid[sy][sx] === "v" ? "v" : ".";
28
+ }
29
+ out.push(row);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export const MARK_SM = downsample(MARK_LG, 16, 7);
35
+
36
+ export default { MARK_LG, MARK_SM };
package/src/setup.js ADDED
@@ -0,0 +1,212 @@
1
+ // setup.js — the interactive first-run wizard. Full-width TUI: selectable option
2
+ // cards on the left, the cat + a speech bubble on the right that changes per step.
3
+ // Arrow keys move, enter selects, esc/ctrl-c quits.
4
+ //
5
+ // Steps (v1 — API-key only for the frontier leg; no subscription relay):
6
+ // 1. which coding tool
7
+ // 2. how to reach the frontier model (detected key / paste / skip)
8
+ // 3. enable the private uncensored leg (SolRouter sign-in / skip)
9
+ // 4. done — save config, offer to launch
10
+
11
+ import readline from "node:readline";
12
+ import { catLines, markLines, catWidth, PAL, paint, fg, RESET } from "./banner.js";
13
+ import { loadConfig, saveAuth, loadAuth, isLoggedIn, HOME_DIR } from "./config.js";
14
+ import { browserLogin } from "./auth.js";
15
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ const visLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
19
+ const W = () => process.stdout.columns || 100;
20
+ const clear = () => process.stdout.write("\x1b[2J\x1b[H");
21
+ const hideCur = () => process.stdout.write("\x1b[?25l");
22
+ const showCur = () => process.stdout.write("\x1b[?25h");
23
+
24
+ // A rounded speech bubble in accent, tail pointing right (toward the cat).
25
+ function bubble(lines) {
26
+ const w = Math.max(...lines.map(l => l.length)) + 4;
27
+ const A = PAL.accent;
28
+ const out = [];
29
+ out.push(fg(A) + "╭" + "─".repeat(w) + "╮" + RESET);
30
+ for (const l of lines) out.push(fg(A) + "│ " + RESET + paint(PAL.ink, l.padEnd(w - 2)) + fg(A) + " │" + RESET);
31
+ out.push(fg(A) + "╰" + "─".repeat(w) + "╯" + RESET);
32
+ out.push(" ".repeat(Math.min(w - 2, 6)) + fg(A) + "╲" + RESET);
33
+ return out;
34
+ }
35
+
36
+ // Right block = bubble (with the step's question) stacked above the cat.
37
+ function rightBlock(question) {
38
+ const cat = catLines();
39
+ const b = bubble(question);
40
+ return [...b, "", ...cat, "", paint(PAL.faint, " the router, in the hoodie")];
41
+ }
42
+
43
+ // Left block = header + step + rendered body lines (already ANSI).
44
+ function leftBlock(step, bodyLines) {
45
+ const L = [];
46
+ const mk = markLines(); // real wave mark
47
+ L.push(mk[0] || "");
48
+ L.push((mk[1] || "") + " " + paint(PAL.accent, "SOLROUTER", true));
49
+ L.push((mk[2] || "") + " " + paint(PAL.muted, "private routing for your coding agent"));
50
+ for (let i = 3; i < mk.length; i++) L.push(mk[i]);
51
+ L.push("");
52
+ L.push(paint(PAL.faint, `STEP ${step} / 3`));
53
+ L.push("");
54
+ bodyLines.forEach(l => L.push(l));
55
+ return L;
56
+ }
57
+
58
+ // One option card (two lines): selected gets an accent bar + bright text.
59
+ function card(name, desc, selected) {
60
+ const bar = selected ? fg(PAL.accent) + "▐ " + RESET : " ";
61
+ const nm = selected ? paint(PAL.accent, "▸ " + name, true) : paint(PAL.ink, " " + name);
62
+ const ds = paint(selected ? PAL.muted : PAL.faint, " " + desc);
63
+ return [bar + nm, bar + ds, ""];
64
+ }
65
+
66
+ function compose(step, question, bodyLines) {
67
+ const left = leftBlock(step, bodyLines);
68
+ const right = rightBlock(question);
69
+ const leftW = Math.min(58, Math.max(...left.map(visLen)) + 2);
70
+ const gap = 6;
71
+ const rows = Math.max(left.length, right.length);
72
+ const out = [""];
73
+ for (let i = 0; i < rows; i++) {
74
+ const l = left[i] || "";
75
+ const r = right[i] || "";
76
+ const pad = Math.max(gap, leftW + gap - visLen(l));
77
+ out.push(" " + l + " ".repeat(pad) + r);
78
+ }
79
+ out.push("");
80
+ out.push(" " + paint(PAL.faint, "↑↓ move ↵ select esc quit"));
81
+ return out.join("\n");
82
+ }
83
+
84
+ function render(step, question, bodyLines) { clear(); process.stdout.write(compose(step, question, bodyLines)); }
85
+
86
+ function saveSetup(patch) {
87
+ if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true });
88
+ const path = join(HOME_DIR, "config.json");
89
+ let cur = {};
90
+ try { cur = JSON.parse(readFileSync(path, "utf8")); } catch {}
91
+ writeFileSync(path, JSON.stringify({ ...cur, ...patch, setupComplete: true }, null, 2));
92
+ }
93
+
94
+ const TOOLS = [
95
+ { id: "claude", name: "Claude Code", desc: "Anthropic CLI — the default" },
96
+ { id: "cursor", name: "Cursor", desc: "VS Code fork, custom base URL" },
97
+ { id: "cline", name: "Cline", desc: "VS Code extension" },
98
+ { id: "other", name: "Other / OpenAI-compatible", desc: "aider, Zed, Continue, SDKs" },
99
+ ];
100
+
101
+ /** Run the wizard. Resolves { tool, launch } or rejects on quit. */
102
+ export function runSetup() {
103
+ return new Promise((resolve, reject) => {
104
+ if (!process.stdin.isTTY) return reject(new Error("setup needs an interactive terminal"));
105
+ readline.emitKeypressEvents(process.stdin);
106
+ process.stdin.setRawMode(true);
107
+ hideCur();
108
+
109
+ const finish = (val, err) => {
110
+ process.stdin.setRawMode(false); process.stdin.pause(); showCur();
111
+ process.stdout.write("\n");
112
+ err ? reject(err) : resolve(val);
113
+ };
114
+
115
+ const envKey = process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY || "";
116
+ const state = { step: 1, tool: 0, keyChoice: 0, loginChoice: 0, typing: false, buf: "" };
117
+
118
+ function keyOptions() {
119
+ return envKey
120
+ ? [`Use detected key (…${envKey.slice(-4)})`, "Paste a different key", "Skip — private leg only"]
121
+ : ["Paste an Anthropic/OpenAI API key", "Skip — private leg only"];
122
+ }
123
+
124
+ function draw() {
125
+ if (state.step === 1) {
126
+ const body = TOOLS.flatMap((t, i) => card(t.name, t.desc, i === state.tool));
127
+ render(1, ["Which coding tool", "are you using?"], body);
128
+ } else if (state.step === 2) {
129
+ if (state.typing) {
130
+ const masked = "•".repeat(state.buf.length);
131
+ render(2, ["Paste your API key", "then press ↵"],
132
+ [paint(PAL.ink, " Key: ") + paint(PAL.accent, masked || "_"),
133
+ "", paint(PAL.faint, " it's stored only on this machine")]);
134
+ } else {
135
+ const opts = keyOptions();
136
+ const body = opts.flatMap((o, i) => card(o, "", i === state.keyChoice));
137
+ render(2, ["How should I reach", "the frontier model?"], body);
138
+ }
139
+ } else if (state.step === 3) {
140
+ const opts = ["Sign in (opens browser)", "Skip for now"];
141
+ const body = opts.flatMap((o, i) => card(o,
142
+ i === 0 ? "the uncensored model, refusals reroute here" : "add it later with `solrouter login`",
143
+ i === state.loginChoice));
144
+ render(3, ["Want the uncensored", "model too?"], body);
145
+ } else if (state.step === 4) {
146
+ const opts = ["Launch now", "Exit — I'll run it later"];
147
+ const body = [paint(PAL.muted, " Tool: ") + paint(PAL.ink, TOOLS[state.tool].name),
148
+ paint(PAL.muted, " Frontier: ") + paint(PAL.ink, envKey || state.buf ? "your API key" : "not set"),
149
+ paint(PAL.muted, " Private: ") + paint(PAL.ink, isLoggedIn() ? "enabled" : "off"),
150
+ "", ...opts.flatMap((o, i) => card(o, "", i === state.loginChoice))];
151
+ render(4, ["You're all set!", "≈"], body);
152
+ }
153
+ }
154
+
155
+ async function onKey(str, key) {
156
+ const name = key?.name;
157
+ if (key?.ctrl && name === "c") return finish(null, new Error("cancelled"));
158
+ if (name === "escape") return finish(null, new Error("cancelled"));
159
+
160
+ if (state.typing) { // API key entry
161
+ if (name === "return") {
162
+ if (state.buf) { saveSetup({ tool: TOOLS[state.tool].id, frontier: { apiKey: state.buf } }); }
163
+ state.typing = false; state.step = 3; draw(); return;
164
+ }
165
+ if (name === "backspace") { state.buf = state.buf.slice(0, -1); draw(); return; }
166
+ if (str && !key.ctrl && str.length === 1 && str >= " ") { state.buf += str; draw(); }
167
+ return;
168
+ }
169
+
170
+ const move = (cur, len) => name === "up" ? (cur - 1 + len) % len : name === "down" ? (cur + 1) % len : cur;
171
+
172
+ if (state.step === 1) {
173
+ if (name === "up" || name === "down") { state.tool = move(state.tool, TOOLS.length); draw(); }
174
+ else if (name === "return") { state.step = 2; draw(); }
175
+ } else if (state.step === 2) {
176
+ const opts = keyOptions();
177
+ if (name === "up" || name === "down") { state.keyChoice = move(state.keyChoice, opts.length); draw(); }
178
+ else if (name === "return") {
179
+ const choice = opts[state.keyChoice];
180
+ if (choice.startsWith("Paste")) { state.typing = true; state.buf = ""; draw(); }
181
+ else { if (envKey && state.keyChoice === 0) saveSetup({ tool: TOOLS[state.tool].id }); state.step = 3; draw(); }
182
+ }
183
+ } else if (state.step === 3) {
184
+ if (name === "up" || name === "down") { state.loginChoice = move(state.loginChoice, 2); draw(); }
185
+ else if (name === "return") {
186
+ if (state.loginChoice === 0) {
187
+ render(3, ["Opening your", "browser…"], [paint(PAL.faint, " finish signing in, then come back")]);
188
+ try { await browserLogin(); } catch (e) { /* leave private off */ }
189
+ }
190
+ state.loginChoice = 0; state.step = 4; draw();
191
+ }
192
+ } else if (state.step === 4) {
193
+ if (name === "up" || name === "down") { state.loginChoice = move(state.loginChoice, 2); draw(); }
194
+ else if (name === "return") {
195
+ saveSetup({ tool: TOOLS[state.tool].id });
196
+ finish({ tool: TOOLS[state.tool].id, launch: state.loginChoice === 0 });
197
+ }
198
+ }
199
+ }
200
+
201
+ process.stdin.on("keypress", (s, k) => { onKey(s, k).catch(err => finish(null, err)); });
202
+ draw();
203
+ });
204
+ }
205
+
206
+ /** For previewing the layout without a TTY (testing only). */
207
+ export function previewFrame() {
208
+ const body = TOOLS.flatMap((t, i) => card(t.name, t.desc, i === 0));
209
+ return compose(1, ["Which coding tool", "are you using?"], body);
210
+ }
211
+
212
+ export default { runSetup, previewFrame };