@gleapai/kai-bridge 0.10.2 → 0.12.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/README.md +19 -4
- package/bin/kai-bridge.mjs +116 -60
- package/npm-shrinkwrap.json +2 -14
- package/package.json +4 -2
- package/src/api.mjs +10 -0
- package/src/config.mjs +1 -0
- package/src/daemon.mjs +118 -7
- package/src/deps.mjs +26 -5
- package/src/help.mjs +115 -0
- package/src/logs.mjs +62 -0
- package/src/ps.mjs +1 -1
- package/src/service.mjs +1 -1
- package/src/setup.mjs +412 -129
- package/src/tui.mjs +76 -0
- package/src/workspace.mjs +75 -21
package/src/setup.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
// Interactive onboarding —
|
|
1
|
+
// Interactive onboarding — one screen, four states (see deriveMode):
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 5. say it's done, nicely
|
|
3
|
+
// first run the guided steps: pair · background service · agent sign-ins
|
|
4
|
+
// ready the machine's state and a short menu (Enter exits)
|
|
5
|
+
// attention the same, with what is missing first (Enter fixes it)
|
|
6
|
+
// disconnected the device was removed in the dashboard; Enter reconnects
|
|
8
7
|
//
|
|
9
|
-
// Runs from `kai-bridge setup`, from a bare `kai-bridge
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// Runs from `kai-bridge setup`, from a bare `kai-bridge`, and (when npm
|
|
9
|
+
// shows script output — `--foreground-scripts`) straight from
|
|
10
|
+
// `npm i -g @gleapai/kai-bridge`'s postinstall. Every action re-reads the
|
|
11
|
+
// machine's state afterwards, so the screen doubles as "reconnect",
|
|
12
|
+
// "add a harness", "start the service" and "log out".
|
|
14
13
|
|
|
15
14
|
import { spawn } from "node:child_process";
|
|
16
|
-
import {
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { homedir, platform } from "node:os";
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { createInterface } from "node:readline";
|
|
19
19
|
|
|
@@ -22,6 +22,9 @@ import { KAI_HOME, deviceDefaults, loadConfig, saveConfig } from "./config.mjs";
|
|
|
22
22
|
import { describeHarnesses, installHarness, HARNESS_INFO } from "./harnesses.mjs";
|
|
23
23
|
import { ambientConfigDir, describeProfiles, loginCommand } from "./profiles.mjs";
|
|
24
24
|
import { install, isInstalled, isEphemeralBinPath, uninstall } from "./service.mjs";
|
|
25
|
+
import { collectProcessList, formatAge } from "./ps.mjs";
|
|
26
|
+
import { installedVersionOrNull } from "./selfupdate.mjs";
|
|
27
|
+
import { banner, layout, palette, panel, terminalColumns } from "./tui.mjs";
|
|
25
28
|
|
|
26
29
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
30
|
|
|
@@ -84,7 +87,19 @@ const out = (s = "") => process.stdout.write(s + "\n");
|
|
|
84
87
|
*/
|
|
85
88
|
export async function pairDevice({ config, name, print = out }) {
|
|
86
89
|
const api = new BridgeApi({ apiBase: config.apiBase });
|
|
87
|
-
|
|
90
|
+
// Minted once per install and sent with every pairing: the server matches
|
|
91
|
+
// on it, so re-pairing (new name, lost token) never creates a second device.
|
|
92
|
+
if (!config.machineId) {
|
|
93
|
+
config.machineId = randomUUID();
|
|
94
|
+
saveConfig(config);
|
|
95
|
+
}
|
|
96
|
+
// The real version: the server stores it on approve and shows it in the devices list.
|
|
97
|
+
const dev = {
|
|
98
|
+
...deviceDefaults(),
|
|
99
|
+
name: name || deviceDefaults().name,
|
|
100
|
+
version: installedVersionOrNull() ?? "0.0.0",
|
|
101
|
+
machineId: config.machineId,
|
|
102
|
+
};
|
|
88
103
|
const start = await api.pairStart(dev);
|
|
89
104
|
const url = start.url || `${config.appBase}/connect-device?code=${start.code}`;
|
|
90
105
|
print(`\nOpen this link to connect "${dev.name}" to your Gleap account:\n\n ${url}\n\nCode: ${start.code}\n`);
|
|
@@ -99,15 +114,16 @@ export async function pairDevice({ config, name, print = out }) {
|
|
|
99
114
|
await sleep(3000);
|
|
100
115
|
const poll = await api.pairPoll(start.pollToken);
|
|
101
116
|
if (poll.status === "approved") {
|
|
102
|
-
config.device = { id: poll.device.id, name: poll.device.name, token: poll.token, organisationId: poll.device.organisationId, userId: poll.device.userId };
|
|
117
|
+
config.device = { id: poll.device.id, name: poll.device.name, token: poll.token, organisationId: poll.device.organisationId, organisationName: poll.device.organisationName ?? null, userId: poll.device.userId };
|
|
103
118
|
saveConfig(config);
|
|
104
119
|
print(`\nConnected as ${poll.device.name} (${poll.device.organisationName ?? poll.device.organisationId}).`);
|
|
105
120
|
return poll.device;
|
|
106
121
|
}
|
|
107
|
-
if (poll.status === "denied"
|
|
122
|
+
if (poll.status === "denied") throw new Error("pairing denied in the dashboard. Run `kai-bridge` to try again.");
|
|
123
|
+
if (poll.status === "expired") throw new Error("pairing code expired (codes last 10 minutes). Run `kai-bridge` to get a fresh one.");
|
|
108
124
|
process.stdout.write(".");
|
|
109
125
|
}
|
|
110
|
-
throw new Error("pairing timed out");
|
|
126
|
+
throw new Error("pairing timed out (codes last 10 minutes). Run `kai-bridge` to get a fresh one.");
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
/**
|
|
@@ -124,7 +140,7 @@ export async function logout({ config = loadConfig(), print = out } = {}) {
|
|
|
124
140
|
try {
|
|
125
141
|
await new BridgeApi({ apiBase: config.apiBase, token: config.device.token }).logout();
|
|
126
142
|
} catch {
|
|
127
|
-
print("(Couldn't reach Gleap to revoke the pairing — it was removed locally; you can also remove it under Kai Code → Settings →
|
|
143
|
+
print("(Couldn't reach Gleap to revoke the pairing — it was removed locally; you can also remove it under Kai Code → Settings → Kai Code Bridge.)");
|
|
128
144
|
}
|
|
129
145
|
if (isServiceHome() && isInstalled()) {
|
|
130
146
|
uninstall();
|
|
@@ -149,140 +165,407 @@ async function harnessRows(config) {
|
|
|
149
165
|
});
|
|
150
166
|
}
|
|
151
167
|
|
|
168
|
+
/** "2.1.258 (Claude Code)" / "codex-cli 0.153.4" → "2.1.258"; anything else unchanged. */
|
|
169
|
+
const shortVersion = (v) => (v ? (/\d+\.\d+\.\d+/.exec(v)?.[0] ?? v) : null);
|
|
170
|
+
|
|
171
|
+
// ── Screen state ──────────────────────────────────────────────────────
|
|
172
|
+
//
|
|
173
|
+
// Everything the wizard draws or decides comes from this one snapshot,
|
|
174
|
+
// so the screen and its menu can never disagree. `probe` asks Gleap
|
|
175
|
+
// whether the pairing still exists (read-only call): a device removed in
|
|
176
|
+
// the dashboard otherwise looks "connected" until the daemon's next 403.
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @returns {{ paired, connection: "none"|"ok"|"revoked"|"unknown", device, service, harnesses, version, appBase }}
|
|
180
|
+
*/
|
|
181
|
+
export async function collectSetupState({ config, kaiHome = KAI_HOME, probe = true, fetchImpl } = {}) {
|
|
182
|
+
const paired = !!config.device?.token;
|
|
183
|
+
let connection = paired ? "unknown" : "none";
|
|
184
|
+
if (paired && probe) {
|
|
185
|
+
try {
|
|
186
|
+
const api = new BridgeApi({ apiBase: config.apiBase, token: config.device.token, ...(fetchImpl ? { fetchImpl } : {}) });
|
|
187
|
+
await api.request("GET", "/gleapcode/bridge/devices/me/pending", undefined, { timeoutMs: 8_000 });
|
|
188
|
+
connection = "ok";
|
|
189
|
+
} catch (err) {
|
|
190
|
+
connection = err?.status === 401 || err?.status === 403 ? "revoked" : "unknown";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const daemon = collectProcessList({ kaiHome }).daemon;
|
|
194
|
+
const managed = isServiceHome();
|
|
195
|
+
const service = { managed, installed: managed ? isInstalled() : false, running: daemon.running, startedAt: daemon.startedAt };
|
|
196
|
+
const harnesses = (await harnessRows(config)).map((h) => ({
|
|
197
|
+
id: h.id,
|
|
198
|
+
label: h.label,
|
|
199
|
+
installed: h.installed,
|
|
200
|
+
version: shortVersion(h.version),
|
|
201
|
+
authState: h.installed ? (h.profile?.authState ?? "signed_out") : "missing",
|
|
202
|
+
account: h.profile?.account ?? null,
|
|
203
|
+
}));
|
|
204
|
+
return { paired, connection, device: config.device ?? null, service, harnesses, version: installedVersionOrNull(), appBase: config.appBase };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Which screen to show:
|
|
209
|
+
* first_run — not paired
|
|
210
|
+
* disconnected — paired, but Gleap says the device is gone
|
|
211
|
+
* attention — paired, something the daemon needs is missing
|
|
212
|
+
* ready — nothing to do
|
|
213
|
+
* `fixes` are the attention items, in the order "finish all" runs them.
|
|
214
|
+
*/
|
|
215
|
+
export function deriveMode(state, { ephemeral = false } = {}) {
|
|
216
|
+
if (!state.paired) return { mode: "first_run", fixes: [] };
|
|
217
|
+
if (state.connection === "revoked") return { mode: "disconnected", fixes: [] };
|
|
218
|
+
const fixes = [];
|
|
219
|
+
if (state.service.managed && !ephemeral) {
|
|
220
|
+
if (!state.service.installed) fixes.push({ id: "service", label: "Install the background service" });
|
|
221
|
+
else if (!state.service.running) fixes.push({ id: "service", label: "Start the background service" });
|
|
222
|
+
}
|
|
223
|
+
if (!state.harnesses.some((h) => h.authState === "signed_in")) fixes.push({ id: "agents", label: "Sign in to a coding agent" });
|
|
224
|
+
return { mode: fixes.length > 0 ? "attention" : "ready", fixes };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The numbered menu for a returning user. Never more than five entries. */
|
|
228
|
+
export function buildMenu(state, { mode, fixes }) {
|
|
229
|
+
if (mode === "disconnected") {
|
|
230
|
+
return {
|
|
231
|
+
items: [
|
|
232
|
+
{ id: "reconnect", label: "Reconnect this machine", recommended: true },
|
|
233
|
+
{ id: "logout", label: "Remove the service and quit" },
|
|
234
|
+
],
|
|
235
|
+
enter: { id: "reconnect", label: "Reconnect" },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const items = fixes.map((f, i) => ({ ...f, recommended: i === 0 }));
|
|
239
|
+
const agentsOpen = state.harnesses.some((h) => h.authState !== "signed_in");
|
|
240
|
+
if (agentsOpen && !fixes.some((f) => f.id === "agents")) items.push({ id: "agents", label: "Sign in or install coding agents" });
|
|
241
|
+
if (mode === "ready" && state.service.managed && state.service.running) items.push({ id: "service", label: "Restart the background service" });
|
|
242
|
+
items.push({ id: "dashboard", label: "Open the dashboard", hint: `${state.appBase}/dashboard` });
|
|
243
|
+
items.push({ id: "reconnect", label: "Reconnect this machine" });
|
|
244
|
+
items.push({ id: "logout", label: "Log out and quit" });
|
|
245
|
+
const enter =
|
|
246
|
+
fixes.length > 1 ? { id: "fix_all", label: "Finish all" } : fixes.length === 1 ? { id: fixes[0].id, label: fixes[0].label } : { id: "exit", label: "Exit" };
|
|
247
|
+
return { items: items.slice(0, 5), enter };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Drawing ───────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
const STATUS_PILL = {
|
|
253
|
+
first_run: (p) => p.dim("○ not set up"),
|
|
254
|
+
disconnected: (p) => p.red("● disconnected"),
|
|
255
|
+
attention: (p) => p.yellow("● needs attention"),
|
|
256
|
+
ready: (p, state) => p.green(state.service.running ? "● online" : "● connected"),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Header + the two panels, as lines. Pure: pass `paint`/`cols` in tests. */
|
|
260
|
+
export function renderOverview(state, { mode }, { paint = palette(), cols = terminalColumns() } = {}) {
|
|
261
|
+
const p = paint;
|
|
262
|
+
const head = banner({ left: `${p.bold("KAI CODE BRIDGE")} ${p.dim(`v${state.version ?? "?"}`)}`, right: STATUS_PILL[mode](p, state), cols, paint: p });
|
|
263
|
+
|
|
264
|
+
const machine = [];
|
|
265
|
+
machine.push(state.device?.name ?? deviceDefaults().name);
|
|
266
|
+
if (!state.paired) machine.push(p.yellow("not connected to Gleap"));
|
|
267
|
+
else if (state.connection === "revoked") machine.push(p.red("no longer connected"));
|
|
268
|
+
else machine.push(`${state.device.organisationName ? `${state.device.organisationName} ${p.dim("· Gleap")}` : "connected to Gleap"}${state.connection === "unknown" ? p.dim(" · offline?") : ""}`);
|
|
269
|
+
if (!state.service.managed) machine.push(`service ${p.dim("n/a (custom KAI_HOME)")}`);
|
|
270
|
+
else if (state.service.running) machine.push(`service ${p.green("running")} ${p.dim(`· up ${formatAge(state.service.startedAt)}`)}`);
|
|
271
|
+
else if (state.service.installed) machine.push(`service ${(mode === "disconnected" ? p.dim : p.yellow)("stopped")}`);
|
|
272
|
+
else machine.push(`service ${(state.paired ? p.yellow : p.dim)("not installed")}`);
|
|
273
|
+
|
|
274
|
+
const agents = state.harnesses.map((h) => {
|
|
275
|
+
const status =
|
|
276
|
+
h.authState === "signed_in" ? p.green("signed in".padEnd(13)) : h.authState === "missing" ? p.dim("not installed".padEnd(13)) : p.yellow("signed out".padEnd(13));
|
|
277
|
+
return `${h.id.padEnd(7)} ${status}${h.version ? ` ${p.dim(h.version)}` : ""}`;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const panels = layout([panel("machine", machine, { paint: p, inner: 29 }), panel("agents", agents, { paint: p })], { cols });
|
|
281
|
+
return [...head, "", ...panels];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Parse "1,3" / "2 3" / "" against a menu; Enter → the recommended action. */
|
|
285
|
+
export function parseMenuChoice(answer, menu) {
|
|
286
|
+
const picked = String(answer || "")
|
|
287
|
+
.split(/[\s,]+/)
|
|
288
|
+
.filter(Boolean)
|
|
289
|
+
.map((n) => menu.items[Number(n) - 1]?.id)
|
|
290
|
+
.filter(Boolean);
|
|
291
|
+
return picked.length > 0 ? picked : [menu.enter.id];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Steps (each one re-checks its own state, so they are safe to re-run) ──
|
|
295
|
+
|
|
296
|
+
async function serviceStep({ state, binPath, prompter, confirm = true }) {
|
|
297
|
+
if (!state.service.managed) {
|
|
298
|
+
out(p().dim("(Custom KAI_HOME — skipping the background service; it always runs on the machine's default ~/.kai.)"));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (binPath && isEphemeralBinPath(binPath)) {
|
|
302
|
+
out(p().dim("(Running via npx — install it with `npm i -g @gleapai/kai-bridge`, then `kai-bridge service install`.)"));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (state.service.installed && state.service.running && confirm) {
|
|
306
|
+
out("Background service: already running.");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (confirm && !state.service.installed) {
|
|
310
|
+
out(` Kai only reaches this machine while the bridge is running. ${p().yellow("Recommended:")} keep it`);
|
|
311
|
+
out(" running in the background — it starts at login and restarts after crashes.");
|
|
312
|
+
out("");
|
|
313
|
+
const yes = await prompter.yesNo(` ${p().blue("?")} Keep Kai reachable in the background?`, true);
|
|
314
|
+
if (!yes) {
|
|
315
|
+
out(p().dim("Skipped — run `kai-bridge start` whenever you want this machine available."));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const res = install({ binPath, logDir: join(KAI_HOME, "logs") });
|
|
320
|
+
out(`${p().green("✓")} Background service ${state.service.installed ? "restarted" : "installed"} (${res.kind})${state.service.installed ? "." : " — running now, and again after every restart."}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function agentsStep({ config, prompter }) {
|
|
324
|
+
let rows = await harnessRows(config);
|
|
325
|
+
out(p().bold("Coding agents on this machine"));
|
|
326
|
+
for (const [i, h] of rows.entries()) out(` ${p().magenta(String(i + 1))} ${rowLine(h)}`);
|
|
327
|
+
const pick = await prompter.ask(`${p().blue("?")} Sign in / install now? Numbers like \`1,3\`, or Enter to skip: `);
|
|
328
|
+
const picked = pick
|
|
329
|
+
.split(/[\s,]+/)
|
|
330
|
+
.map((n) => rows[Number(n) - 1])
|
|
331
|
+
.filter(Boolean);
|
|
332
|
+
for (const h of picked) {
|
|
333
|
+
if (!h.installed) {
|
|
334
|
+
out(`Installing ${h.label}…`);
|
|
335
|
+
const res = await installHarness(h.id, { kaiHome: KAI_HOME, onLog: out });
|
|
336
|
+
if (!res.ok) {
|
|
337
|
+
out(`Couldn't install ${h.label} — skipping (guide: ${HARNESS_INFO[h.id]?.label} docs).`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (h.profile?.authState === "signed_in") {
|
|
342
|
+
out(`${h.label} is already signed in — nothing to do.`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const configDir = h.resolvedProfile?.configDir ?? ambientConfigDir(h.id);
|
|
346
|
+
const c = loginCommand(h.id, configDir);
|
|
347
|
+
if (!c) {
|
|
348
|
+
out(`${h.label} has no login command — skipping.`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
out(`\nSigning in to ${h.label} — follow its own prompts:`);
|
|
352
|
+
await new Promise((res) => spawn(c.cmd, c.args, { env: c.env, stdio: "inherit" }).on("close", res));
|
|
353
|
+
}
|
|
354
|
+
if (picked.length > 0) {
|
|
355
|
+
rows = await harnessRows(config);
|
|
356
|
+
out("");
|
|
357
|
+
for (const h of rows) out(` ${rowLine(h)}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
152
361
|
const rowLine = (h) => {
|
|
153
362
|
const status = !h.installed
|
|
154
|
-
? "not installed"
|
|
363
|
+
? p().dim("not installed")
|
|
155
364
|
: h.profile?.authState === "signed_in"
|
|
156
|
-
? `✓ signed in${h.profile.account ? ` as ${h.profile.account}` : ""}`
|
|
157
|
-
: "
|
|
365
|
+
? p().green(`✓ signed in${h.profile.account ? ` as ${h.profile.account}` : ""}`)
|
|
366
|
+
: p().yellow("signed out");
|
|
158
367
|
return `${h.label.padEnd(12)} ${status}`;
|
|
159
368
|
};
|
|
160
369
|
|
|
370
|
+
// The preview warm-up and the agent's browser tools need a real browser.
|
|
371
|
+
// System Chrome needs nothing; otherwise Playwright's Chromium is
|
|
372
|
+
// downloaded once here (the daemon would do it lazily before the first
|
|
373
|
+
// preview, which is a bad moment to wait for 150 MB).
|
|
374
|
+
async function browserStep({ quiet = false } = {}) {
|
|
375
|
+
if (!quiet) out(p().bold("Browser for previews"));
|
|
376
|
+
try {
|
|
377
|
+
const { ensurePreviewBrowser } = await import("./preview.mjs");
|
|
378
|
+
const { createLogger } = await import("./daemon.mjs");
|
|
379
|
+
let announced = false;
|
|
380
|
+
const res = await ensurePreviewBrowser({
|
|
381
|
+
log: (level, event, data) => {
|
|
382
|
+
createLogger()(level, event, data);
|
|
383
|
+
if (event === "browser.install.start" && !announced) {
|
|
384
|
+
announced = true;
|
|
385
|
+
out(" Downloading Chromium for previews (one time, ~150 MB)…");
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
if (res.browser === "chrome") {
|
|
390
|
+
if (!quiet) out(` ${p().green("✓")} Using Google Chrome on this machine.`);
|
|
391
|
+
} else if (res.ok) {
|
|
392
|
+
if (!quiet || res.installed) out(` ${p().green("✓")} ${res.installed ? "Chromium installed for previews." : "Bundled Chromium already installed."}`);
|
|
393
|
+
} else out(` No browser for previews yet — they will try again later (${res.error}).`);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (!quiet) out(` Skipped (${err?.message || err}).`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function openDashboard(appBase) {
|
|
400
|
+
const url = `${appBase}/dashboard`;
|
|
401
|
+
const opener = platform() === "darwin" ? ["open", [url]] : platform() === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
402
|
+
try {
|
|
403
|
+
spawn(opener[0], opener[1], { detached: true, stdio: "ignore" }).on("error", () => undefined).unref();
|
|
404
|
+
} catch {
|
|
405
|
+
/* printing the link is enough */
|
|
406
|
+
}
|
|
407
|
+
out(` ${url}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let paintCache;
|
|
411
|
+
const p = () => (paintCache ??= palette());
|
|
412
|
+
|
|
413
|
+
/** The first-run steps, in order. Shown as a rail, never as a menu. */
|
|
414
|
+
export const SETUP_STEPS = [
|
|
415
|
+
{ id: "connect", label: "Connect", hint: "open a link, approve it in the dashboard" },
|
|
416
|
+
{ id: "service", label: "Service", hint: "keep Kai running at login" },
|
|
417
|
+
{ id: "agents", label: "Agents", hint: "sign in to Claude Code, Codex or Cursor" },
|
|
418
|
+
];
|
|
419
|
+
|
|
161
420
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
421
|
+
* `● Connect ──── ● Service ──── ◉ Agents ──── ○ Browser step 3 of 4`
|
|
422
|
+
* Done steps are green, the current one bold, the rest dim.
|
|
164
423
|
*/
|
|
165
|
-
export
|
|
424
|
+
export function stepRail(current, { paint = palette(), steps = SETUP_STEPS } = {}) {
|
|
425
|
+
const parts = steps.map((s, i) => (i < current ? paint.green(`● ${s.label}`) : i === current ? paint.bold(`◉ ${s.label}`) : paint.dim(`○ ${s.label}`)));
|
|
426
|
+
const rail = parts.join(paint.dim(" ──── "));
|
|
427
|
+
return current < steps.length ? `${rail} ${paint.dim(`step ${current + 1} of ${steps.length}`)}` : rail;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── The wizard ────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* `kai-bridge setup` / a bare `kai-bridge`. One screen, four states (see
|
|
434
|
+
* deriveMode): a first run walks through the steps; every later run
|
|
435
|
+
* shows the machine's state and a short menu whose Enter does the one
|
|
436
|
+
* recommended thing. `binPath` is the CLI entry (for the service plist).
|
|
437
|
+
*/
|
|
438
|
+
export async function runSetup({ binPath, prompter = makePrompter(), paint, cols } = {}) {
|
|
166
439
|
const config = loadConfig();
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
440
|
+
paintCache = paint ?? palette();
|
|
441
|
+
const draw = (state, derived) => {
|
|
442
|
+
out("");
|
|
443
|
+
for (const line of renderOverview(state, derived, { paint: p(), cols: cols ?? terminalColumns() })) out(line);
|
|
444
|
+
out("");
|
|
445
|
+
};
|
|
446
|
+
const ephemeral = !!(binPath && isEphemeralBinPath(binPath));
|
|
172
447
|
|
|
173
448
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
449
|
+
let state = await collectSetupState({ config });
|
|
450
|
+
let derived = deriveMode(state, { ephemeral });
|
|
451
|
+
|
|
452
|
+
// ── First run: the guided steps ───────────────────────────────
|
|
453
|
+
if (derived.mode === "first_run") {
|
|
454
|
+
draw(state, derived);
|
|
455
|
+
out(` ${p().bold("Let's set this machine up")} ${p().dim(`— ${SETUP_STEPS.length} short steps, about 2 minutes`)}`);
|
|
456
|
+
out("");
|
|
457
|
+
for (const step of SETUP_STEPS) out(` ${p().dim("○")} ${step.label.padEnd(9)} ${p().dim(step.hint)}`);
|
|
458
|
+
out("");
|
|
459
|
+
if (!process.stdin.isTTY) {
|
|
460
|
+
out(p().dim(" Not a terminal — run `kai-bridge setup` interactively to pair this machine."));
|
|
182
461
|
return;
|
|
183
462
|
}
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
463
|
+
if (!(await prompter.yesNo(` ${p().blue("?")} Start now?`, true))) {
|
|
464
|
+
out("");
|
|
465
|
+
out(p().dim(" Later: `kai-bridge setup` picks up right here."));
|
|
466
|
+
return;
|
|
187
467
|
}
|
|
188
|
-
|
|
189
|
-
|
|
468
|
+
const stepHeader = (i) => {
|
|
469
|
+
out("");
|
|
470
|
+
out(` ${stepRail(i, { paint: p() })}`);
|
|
471
|
+
out("");
|
|
472
|
+
};
|
|
473
|
+
stepHeader(0);
|
|
190
474
|
await pairDevice({ config });
|
|
475
|
+
stepHeader(1);
|
|
476
|
+
state = await collectSetupState({ config, probe: false });
|
|
477
|
+
await serviceStep({ state, binPath, prompter });
|
|
478
|
+
stepHeader(2);
|
|
479
|
+
await agentsStep({ config, prompter });
|
|
480
|
+
out("");
|
|
481
|
+
out(` ${stepRail(SETUP_STEPS.length, { paint: p() })}`);
|
|
482
|
+
// Not a step of its own: silent when Chrome or Chromium is already
|
|
483
|
+
// there, a one-line notice when the Chromium download has to run.
|
|
484
|
+
await browserStep({ quiet: true });
|
|
485
|
+
state = await collectSetupState({ config, probe: false });
|
|
486
|
+
derived = deriveMode(state, { ephemeral });
|
|
487
|
+
draw(state, derived);
|
|
488
|
+
out(` ${p().green("✓ All set.")} ${state.device?.name ?? "This machine"} now shows up in Kai Code.`);
|
|
489
|
+
out("");
|
|
490
|
+
out(" The connect page in your browser flips to \"ready\" on its own.");
|
|
491
|
+
out(" Pick this machine in the Run-on menu of any session, and it runs");
|
|
492
|
+
out(" right here — on your own subscription, with your local checkouts");
|
|
493
|
+
out(" and dev servers. Happy shipping!");
|
|
494
|
+
out("");
|
|
495
|
+
out(` ${state.appBase}/dashboard`);
|
|
496
|
+
out("");
|
|
497
|
+
out(p().dim(" Handy later: kai-bridge status · kai-bridge setup · kai-bridge logout"));
|
|
498
|
+
return;
|
|
191
499
|
}
|
|
192
|
-
out("");
|
|
193
500
|
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
} else {
|
|
207
|
-
out("Skipped — run `kai-bridge start` whenever you want this machine available.");
|
|
208
|
-
}
|
|
209
|
-
out("");
|
|
210
|
-
|
|
211
|
-
// 3 ── Harness sign-ins ------------------------------------------
|
|
212
|
-
out("Step 3 — coding agents on this machine:");
|
|
213
|
-
let rows = await harnessRows(config);
|
|
214
|
-
for (const [i, h] of rows.entries()) out(` ${i + 1}. ${rowLine(h)}`);
|
|
215
|
-
const pick = await prompter.ask("Sign in / install now? Numbers like `1,3`, or Enter to skip: ");
|
|
216
|
-
const picked = pick
|
|
217
|
-
.split(/[\s,]+/)
|
|
218
|
-
.map((n) => rows[Number(n) - 1])
|
|
219
|
-
.filter(Boolean);
|
|
220
|
-
for (const h of picked) {
|
|
221
|
-
if (!h.installed) {
|
|
222
|
-
out(`Installing ${h.label}…`);
|
|
223
|
-
const res = await installHarness(h.id, { kaiHome: KAI_HOME, onLog: out });
|
|
224
|
-
if (!res.ok) {
|
|
225
|
-
out(`Couldn't install ${h.label} — skipping (guide: ${HARNESS_INFO[h.id]?.label} docs).`);
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
501
|
+
// ── Returning user: state + menu, until they exit ─────────────
|
|
502
|
+
for (let round = 0; round < 8; round += 1) {
|
|
503
|
+
draw(state, derived);
|
|
504
|
+
const menu = buildMenu(state, derived);
|
|
505
|
+
if (derived.mode === "disconnected") {
|
|
506
|
+
out(` ${p().red("This machine was removed from Gleap")} ${p().dim("(Settings → Kai Code Bridge)")}`);
|
|
507
|
+
out(" Your agent logins are untouched. Reconnecting takes a minute.");
|
|
508
|
+
} else if (derived.mode === "attention") {
|
|
509
|
+
const n = derived.fixes.length;
|
|
510
|
+
out(` ${p().yellow(n === 1 ? "1 thing to finish" : `${n} things to finish`)}`);
|
|
511
|
+
} else {
|
|
512
|
+
out(` ${p().green("✓ All set.")} Pick this machine in the Run-on menu of any session.`);
|
|
228
513
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
514
|
+
out("");
|
|
515
|
+
for (const [i, item] of menu.items.entries()) {
|
|
516
|
+
const tag = item.recommended ? ` ${p().yellow("recommended")}` : item.hint ? ` ${p().dim(item.hint)}` : "";
|
|
517
|
+
out(` ${p().magenta(String(i + 1))} ${tag ? item.label.padEnd(32) + tag : item.label}`);
|
|
232
518
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
519
|
+
out(` ${p().dim("Enter")} ${menu.enter.label}`);
|
|
520
|
+
out("");
|
|
521
|
+
// No terminal (a script, CI, `< /dev/null`): show the state, never act.
|
|
522
|
+
// Reconnecting or logging out from a script is how a healthy machine
|
|
523
|
+
// gets unpaired by accident.
|
|
524
|
+
if (!process.stdin.isTTY) {
|
|
525
|
+
out(p().dim(" Not a terminal — showing the state only. Run `kai-bridge setup` interactively to change it."));
|
|
526
|
+
return;
|
|
238
527
|
}
|
|
239
|
-
|
|
240
|
-
await new Promise((res) => spawn(c.cmd, c.args, { env: c.env, stdio: "inherit" }).on("close", res));
|
|
241
|
-
}
|
|
242
|
-
if (picked.length > 0) {
|
|
243
|
-
rows = await harnessRows(config);
|
|
528
|
+
const answer = await prompter.ask(` ${p().blue("?")} Choose: `);
|
|
244
529
|
out("");
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
530
|
+
const actions = parseMenuChoice(answer, menu);
|
|
531
|
+
const run = async (id) => {
|
|
532
|
+
switch (id) {
|
|
533
|
+
case "exit":
|
|
534
|
+
return true;
|
|
535
|
+
case "fix_all":
|
|
536
|
+
for (const f of derived.fixes) await run(f.id);
|
|
537
|
+
return false;
|
|
538
|
+
case "service":
|
|
539
|
+
await serviceStep({ state, binPath, prompter, confirm: false });
|
|
540
|
+
return false;
|
|
541
|
+
case "agents":
|
|
542
|
+
await agentsStep({ config, prompter });
|
|
543
|
+
return false;
|
|
544
|
+
case "dashboard":
|
|
545
|
+
openDashboard(state.appBase);
|
|
546
|
+
return false;
|
|
547
|
+
case "reconnect":
|
|
548
|
+
await logout({ config });
|
|
549
|
+
await pairDevice({ config });
|
|
550
|
+
if (state.service.managed && !ephemeral) {
|
|
551
|
+
// logout() removed the service; a reconnected machine wants it back.
|
|
552
|
+
const res = install({ binPath, logDir: join(KAI_HOME, "logs") });
|
|
553
|
+
out(`${p().green("✓")} Background service installed (${res.kind}).`);
|
|
554
|
+
}
|
|
555
|
+
return false;
|
|
556
|
+
case "logout":
|
|
557
|
+
await logout({ config });
|
|
558
|
+
return true;
|
|
559
|
+
default:
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
let done = false;
|
|
564
|
+
for (const id of actions) done = (await run(id)) || done;
|
|
565
|
+
if (done) return;
|
|
566
|
+
state = await collectSetupState({ config });
|
|
567
|
+
derived = deriveMode(state, { ephemeral });
|
|
273
568
|
}
|
|
274
|
-
out("");
|
|
275
|
-
|
|
276
|
-
// 5 ── Done -------------------------------------------------------
|
|
277
|
-
const name = config.device?.name ?? "This machine";
|
|
278
|
-
out("──────────────────────────────────────────────────────────────");
|
|
279
|
-
out(`🎉 All set. ${name} now shows up in Kai Code.`);
|
|
280
|
-
out("");
|
|
281
|
-
out("Open Gleap → Kai Code, pick this machine in the Run-on menu,");
|
|
282
|
-
out("and sessions run right here — on your own subscription, with");
|
|
283
|
-
out("your local checkouts and dev servers. Happy shipping!");
|
|
284
|
-
out("");
|
|
285
|
-
out("Handy later: kai-bridge status · kai-bridge setup · kai-bridge logout");
|
|
286
569
|
} finally {
|
|
287
570
|
prompter.close();
|
|
288
571
|
}
|