solrouter 0.2.4 → 0.3.1
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 +60 -5
- package/package.json +1 -1
- package/src/banner.js +54 -51
- package/src/mark.js +37 -0
- package/src/setup.js +215 -0
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 || "
|
|
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": {
|
|
@@ -110,14 +134,39 @@ async function main() {
|
|
|
110
134
|
return;
|
|
111
135
|
}
|
|
112
136
|
|
|
113
|
-
case "
|
|
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
|
|
114
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
|
|
115
166
|
const cfg = loadConfig({ port: flag("--port") ? Number(flag("--port")) : undefined,
|
|
116
167
|
mode: flag("--mode") || undefined });
|
|
117
168
|
const { banner } = await import("../src/banner.js");
|
|
118
|
-
console.log(banner(
|
|
119
|
-
// Pass-through supplies frontier creds per request, so login is not required
|
|
120
|
-
// to start — it only unlocks the private/uncensored leg.
|
|
169
|
+
console.log(banner());
|
|
121
170
|
if (!isLoggedIn()) {
|
|
122
171
|
console.warn("Note: not logged in — the private/uncensored leg is unavailable.");
|
|
123
172
|
console.warn(" The frontier leg uses your tool's own credentials. Run `login` to enable both.\n");
|
|
@@ -128,4 +177,10 @@ async function main() {
|
|
|
128
177
|
}
|
|
129
178
|
}
|
|
130
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
|
+
|
|
131
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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: "#050403",
|
|
12
11
|
};
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
const
|
|
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
|
-
"..
|
|
36
|
-
"..
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
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((CAT[0].length - mw) / 2); // centre horizontally
|
|
52
|
+
const r0 = 16; // centred on the black hoodie
|
|
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
|
-
|
|
50
|
-
|
|
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 <
|
|
53
|
-
let line = "
|
|
54
|
-
for (let x = 0; x <
|
|
55
|
-
const top =
|
|
56
|
-
const bot =
|
|
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;
|
|
59
|
-
else if (bot) line += fg(bot) + "▄" + RESET;
|
|
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
|
-
|
|
68
|
-
export
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
L
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
L.push("
|
|
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,37 @@
|
|
|
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
|
+
// Small mark for the hoodie — kept compact so it sits centred with black around it.
|
|
35
|
+
export const MARK_SM = downsample(MARK_LG, 12, 5);
|
|
36
|
+
|
|
37
|
+
export default { MARK_LG, MARK_SM };
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
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 with a consistent width, tail pointing to the cat.
|
|
25
|
+
function bubble(lines) {
|
|
26
|
+
const iw = Math.max(...lines.map(l => l.length)); // inner text width
|
|
27
|
+
const A = PAL.accent;
|
|
28
|
+
const out = [];
|
|
29
|
+
out.push(fg(A) + "╭" + "─".repeat(iw + 2) + "╮" + RESET);
|
|
30
|
+
for (const l of lines) out.push(fg(A) + "│ " + RESET + paint(PAL.ink, l.padEnd(iw)) + fg(A) + " │" + RESET);
|
|
31
|
+
out.push(fg(A) + "╰" + "─".repeat(iw + 2) + "╯" + RESET);
|
|
32
|
+
out.push(" " + fg(A) + "╲" + RESET); // tail toward the cat below
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Right block = bubble (with the step's question) stacked above the cat, cat indented.
|
|
37
|
+
function rightBlock(question) {
|
|
38
|
+
const cat = catLines().map(l => " " + l);
|
|
39
|
+
const b = bubble(question);
|
|
40
|
+
return [...b, "", ...cat, "", " " + paint(PAL.faint, "the router")];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Left block = header (stacked, no long inline rows) + step + body lines.
|
|
44
|
+
function leftBlock(step, bodyLines) {
|
|
45
|
+
const L = [];
|
|
46
|
+
markLines().forEach(m => L.push(m)); // real wave mark
|
|
47
|
+
L.push(paint(PAL.accent, "SOLROUTER", true));
|
|
48
|
+
L.push(paint(PAL.muted, "private routing for your coding agent"));
|
|
49
|
+
L.push("");
|
|
50
|
+
L.push(paint(PAL.faint, `STEP ${step} / 3`));
|
|
51
|
+
L.push("");
|
|
52
|
+
bodyLines.forEach(l => L.push(l));
|
|
53
|
+
return L;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// One option card (two lines): selected gets an accent bar + bright text.
|
|
57
|
+
function card(name, desc, selected) {
|
|
58
|
+
const bar = selected ? fg(PAL.accent) + "▐ " + RESET : " ";
|
|
59
|
+
const nm = selected ? paint(PAL.accent, "▸ " + name, true) : paint(PAL.ink, " " + name);
|
|
60
|
+
const ds = paint(selected ? PAL.muted : PAL.faint, " " + desc);
|
|
61
|
+
return [bar + nm, bar + ds, ""];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function compose(step, question, bodyLines) {
|
|
65
|
+
const left = leftBlock(step, bodyLines);
|
|
66
|
+
const right = rightBlock(question);
|
|
67
|
+
const termW = W();
|
|
68
|
+
const leftMax = Math.max(...left.map(visLen));
|
|
69
|
+
const rightW = Math.max(...right.map(visLen));
|
|
70
|
+
// Pin the right block to a FIXED column near the right edge, so nothing shoves it
|
|
71
|
+
// around and the bubble stays aligned. Push the cat as far right as fits.
|
|
72
|
+
const rightCol = Math.max(leftMax + 8, termW - rightW - 3);
|
|
73
|
+
const rows = Math.max(left.length, right.length);
|
|
74
|
+
const out = [""];
|
|
75
|
+
for (let i = 0; i < rows; i++) {
|
|
76
|
+
let l = left[i] || "";
|
|
77
|
+
const r = right[i] || "";
|
|
78
|
+
if (visLen(l) > rightCol - 2) l = l.slice(0, rightCol - 2); // safety clamp
|
|
79
|
+
const pad = Math.max(2, rightCol - visLen(l));
|
|
80
|
+
out.push(" " + l + " ".repeat(pad) + r);
|
|
81
|
+
}
|
|
82
|
+
out.push("");
|
|
83
|
+
out.push(" " + paint(PAL.faint, "↑↓ move ↵ select esc quit"));
|
|
84
|
+
return out.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function render(step, question, bodyLines) { clear(); process.stdout.write(compose(step, question, bodyLines)); }
|
|
88
|
+
|
|
89
|
+
function saveSetup(patch) {
|
|
90
|
+
if (!existsSync(HOME_DIR)) mkdirSync(HOME_DIR, { recursive: true });
|
|
91
|
+
const path = join(HOME_DIR, "config.json");
|
|
92
|
+
let cur = {};
|
|
93
|
+
try { cur = JSON.parse(readFileSync(path, "utf8")); } catch {}
|
|
94
|
+
writeFileSync(path, JSON.stringify({ ...cur, ...patch, setupComplete: true }, null, 2));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const TOOLS = [
|
|
98
|
+
{ id: "claude", name: "Claude Code", desc: "Anthropic CLI — the default" },
|
|
99
|
+
{ id: "cursor", name: "Cursor", desc: "VS Code fork, custom base URL" },
|
|
100
|
+
{ id: "cline", name: "Cline", desc: "VS Code extension" },
|
|
101
|
+
{ id: "other", name: "Other / OpenAI-compatible", desc: "aider, Zed, Continue, SDKs" },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** Run the wizard. Resolves { tool, launch } or rejects on quit. */
|
|
105
|
+
export function runSetup() {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
if (!process.stdin.isTTY) return reject(new Error("setup needs an interactive terminal"));
|
|
108
|
+
readline.emitKeypressEvents(process.stdin);
|
|
109
|
+
process.stdin.setRawMode(true);
|
|
110
|
+
hideCur();
|
|
111
|
+
|
|
112
|
+
const finish = (val, err) => {
|
|
113
|
+
process.stdin.setRawMode(false); process.stdin.pause(); showCur();
|
|
114
|
+
process.stdout.write("\n");
|
|
115
|
+
err ? reject(err) : resolve(val);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const envKey = process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY || "";
|
|
119
|
+
const state = { step: 1, tool: 0, keyChoice: 0, loginChoice: 0, typing: false, buf: "" };
|
|
120
|
+
|
|
121
|
+
function keyOptions() {
|
|
122
|
+
return envKey
|
|
123
|
+
? [`Use detected key (…${envKey.slice(-4)})`, "Paste a different key", "Skip — private leg only"]
|
|
124
|
+
: ["Paste an Anthropic/OpenAI API key", "Skip — private leg only"];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function draw() {
|
|
128
|
+
if (state.step === 1) {
|
|
129
|
+
const body = TOOLS.flatMap((t, i) => card(t.name, t.desc, i === state.tool));
|
|
130
|
+
render(1, ["Which coding tool", "are you using?"], body);
|
|
131
|
+
} else if (state.step === 2) {
|
|
132
|
+
if (state.typing) {
|
|
133
|
+
const masked = "•".repeat(state.buf.length);
|
|
134
|
+
render(2, ["Paste your API key", "then press ↵"],
|
|
135
|
+
[paint(PAL.ink, " Key: ") + paint(PAL.accent, masked || "_"),
|
|
136
|
+
"", paint(PAL.faint, " it's stored only on this machine")]);
|
|
137
|
+
} else {
|
|
138
|
+
const opts = keyOptions();
|
|
139
|
+
const body = opts.flatMap((o, i) => card(o, "", i === state.keyChoice));
|
|
140
|
+
render(2, ["How should I reach", "the frontier model?"], body);
|
|
141
|
+
}
|
|
142
|
+
} else if (state.step === 3) {
|
|
143
|
+
const opts = ["Sign in (opens browser)", "Skip for now"];
|
|
144
|
+
const body = opts.flatMap((o, i) => card(o,
|
|
145
|
+
i === 0 ? "the uncensored model, refusals reroute here" : "add it later with `solrouter login`",
|
|
146
|
+
i === state.loginChoice));
|
|
147
|
+
render(3, ["Want the uncensored", "model too?"], body);
|
|
148
|
+
} else if (state.step === 4) {
|
|
149
|
+
const opts = ["Launch now", "Exit — I'll run it later"];
|
|
150
|
+
const body = [paint(PAL.muted, " Tool: ") + paint(PAL.ink, TOOLS[state.tool].name),
|
|
151
|
+
paint(PAL.muted, " Frontier: ") + paint(PAL.ink, envKey || state.buf ? "your API key" : "not set"),
|
|
152
|
+
paint(PAL.muted, " Private: ") + paint(PAL.ink, isLoggedIn() ? "enabled" : "off"),
|
|
153
|
+
"", ...opts.flatMap((o, i) => card(o, "", i === state.loginChoice))];
|
|
154
|
+
render(4, ["You're all set!", "≈"], body);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function onKey(str, key) {
|
|
159
|
+
const name = key?.name;
|
|
160
|
+
if (key?.ctrl && name === "c") return finish(null, new Error("cancelled"));
|
|
161
|
+
if (name === "escape") return finish(null, new Error("cancelled"));
|
|
162
|
+
|
|
163
|
+
if (state.typing) { // API key entry
|
|
164
|
+
if (name === "return") {
|
|
165
|
+
if (state.buf) { saveSetup({ tool: TOOLS[state.tool].id, frontier: { apiKey: state.buf } }); }
|
|
166
|
+
state.typing = false; state.step = 3; draw(); return;
|
|
167
|
+
}
|
|
168
|
+
if (name === "backspace") { state.buf = state.buf.slice(0, -1); draw(); return; }
|
|
169
|
+
if (str && !key.ctrl && str.length === 1 && str >= " ") { state.buf += str; draw(); }
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const move = (cur, len) => name === "up" ? (cur - 1 + len) % len : name === "down" ? (cur + 1) % len : cur;
|
|
174
|
+
|
|
175
|
+
if (state.step === 1) {
|
|
176
|
+
if (name === "up" || name === "down") { state.tool = move(state.tool, TOOLS.length); draw(); }
|
|
177
|
+
else if (name === "return") { state.step = 2; draw(); }
|
|
178
|
+
} else if (state.step === 2) {
|
|
179
|
+
const opts = keyOptions();
|
|
180
|
+
if (name === "up" || name === "down") { state.keyChoice = move(state.keyChoice, opts.length); draw(); }
|
|
181
|
+
else if (name === "return") {
|
|
182
|
+
const choice = opts[state.keyChoice];
|
|
183
|
+
if (choice.startsWith("Paste")) { state.typing = true; state.buf = ""; draw(); }
|
|
184
|
+
else { if (envKey && state.keyChoice === 0) saveSetup({ tool: TOOLS[state.tool].id }); state.step = 3; draw(); }
|
|
185
|
+
}
|
|
186
|
+
} else if (state.step === 3) {
|
|
187
|
+
if (name === "up" || name === "down") { state.loginChoice = move(state.loginChoice, 2); draw(); }
|
|
188
|
+
else if (name === "return") {
|
|
189
|
+
if (state.loginChoice === 0) {
|
|
190
|
+
render(3, ["Opening your", "browser…"], [paint(PAL.faint, " finish signing in, then come back")]);
|
|
191
|
+
try { await browserLogin(); } catch (e) { /* leave private off */ }
|
|
192
|
+
}
|
|
193
|
+
state.loginChoice = 0; state.step = 4; draw();
|
|
194
|
+
}
|
|
195
|
+
} else if (state.step === 4) {
|
|
196
|
+
if (name === "up" || name === "down") { state.loginChoice = move(state.loginChoice, 2); draw(); }
|
|
197
|
+
else if (name === "return") {
|
|
198
|
+
saveSetup({ tool: TOOLS[state.tool].id });
|
|
199
|
+
finish({ tool: TOOLS[state.tool].id, launch: state.loginChoice === 0 });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
process.stdin.on("keypress", (s, k) => { onKey(s, k).catch(err => finish(null, err)); });
|
|
205
|
+
draw();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** For previewing the layout without a TTY (testing only). */
|
|
210
|
+
export function previewFrame() {
|
|
211
|
+
const body = TOOLS.flatMap((t, i) => card(t.name, t.desc, i === 0));
|
|
212
|
+
return compose(1, ["Which coding tool", "are you using?"], body);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export default { runSetup, previewFrame };
|