@gleapai/kai-bridge 0.10.1 → 0.11.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/src/ps.mjs CHANGED
@@ -133,7 +133,7 @@ export function formatProcessList(list, { now = Date.now() } = {}) {
133
133
  lines.push(
134
134
  d.running
135
135
  ? `daemon: running · pid ${d.pid}${d.version ? ` · v${d.version}` : ""} · up ${formatUptime(d.startedAt, now)}`
136
- : `daemon: not running${d.pid ? ` (stale lock, pid ${d.pid})` : ""} — run \`kai-bridge start\` or \`kai-bridge install\``,
136
+ : `daemon: not running${d.pid ? ` (stale lock, pid ${d.pid})` : ""} — run \`kai-bridge start\` or \`kai-bridge service install\``,
137
137
  );
138
138
  if (!list.sessions.length) {
139
139
  lines.push("", "nothing running on this machine");
package/src/service.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // Login-item service: launchd (macOS) / systemd --user (Linux) /
2
2
  // Task Scheduler (Windows). Runs `kai-bridge start` at login, restarts
3
- // on crash, logs under ~/.kai/logs. `kai-bridge install` / `uninstall`.
3
+ // on crash, logs under ~/.kai/logs. `kai-bridge service install|uninstall|restart|status|logs`.
4
4
 
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
package/src/setup.mjs CHANGED
@@ -1,19 +1,19 @@
1
- // Interactive onboarding — the whole first run in one guided flow:
1
+ // Interactive onboarding — one screen, four states (see deriveMode):
2
2
  //
3
- // 1. pair this machine with the user's Gleap account
4
- // 2. offer the background service (start at login, restart on crash)
5
- // 3. offer harness sign-ins (Claude Code / Codex / Cursor), inline
6
- // 4. make sure a browser exists for previews (Chrome, else Chromium)
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` on an unpaired
10
- // machine, and (when npm shows script output — `--foreground-scripts`)
11
- // straight from `npm i -g @gleapai/kai-bridge`'s postinstall. Re-running is
12
- // safe: every step detects its current state and offers the next thing,
13
- // so it doubles as "reconnect", "add a harness", and "log out".
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 { homedir } from "node:os";
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
- const dev = { ...deviceDefaults(), name: name || deviceDefaults().name, version: "0.1.0" };
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" || poll.status === "expired") throw new Error(`pairing ${poll.status}`);
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 → Devices.)");
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
- : "installed · signed out";
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
- * The wizard. `binPath` is the CLI entry (for the service plist);
163
- * re-runnable at any time.
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 async function runSetup({ binPath, prompter = makePrompter() } = {}) {
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
- out("");
168
- out("── Kai Code Bridge ────────────────────────────────────────────────");
169
- out("Run Kai Code sessions on this machine — with your own Claude /");
170
- out("Codex / Cursor login, against your local checkouts.");
171
- out("");
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
- // 1 ── Pairing ---------------------------------------------------
175
- if (config.device?.token) {
176
- out(`This machine is already connected as ${config.device.name}.`);
177
- const choice = (
178
- await prompter.ask("Press Enter to keep it, `r` to reconnect, or `o` to log out and quit: ")
179
- ).toLowerCase();
180
- if (choice === "o") {
181
- await logout({ config });
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 (choice === "r") {
185
- await logout({ config });
186
- await pairDevice({ config });
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
- } else {
189
- out("Step 1 — connect this machine to your Gleap account.");
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
- // 2 ── Background service ---------------------------------------
195
- if (!isServiceHome()) {
196
- out("(Custom KAI_HOME — skipping the background service; it always runs on the machine's default ~/.kai.)");
197
- } else if (binPath && isEphemeralBinPath(binPath)) {
198
- out("(Running via npx — install it with `npm i -g @gleapai/kai-bridge` to add the background service.)");
199
- } else if (isInstalled()) {
200
- out("Background service: already installed — Kai stays reachable here.");
201
- } else if (
202
- await prompter.yesNo("Step 2 — keep Kai reachable in the background? Starts at login, restarts after crashes.", true)
203
- ) {
204
- const res = install({ binPath, logDir: join(KAI_HOME, "logs") });
205
- out(`Installed (${res.kind}) — running now, and again after every restart.`);
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
- if (h.profile?.authState === "signed_in") {
230
- out(`${h.label} is already signed in — nothing to do.`);
231
- continue;
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
- const configDir = h.resolvedProfile?.configDir ?? ambientConfigDir(h.id);
234
- const c = loginCommand(h.id, configDir);
235
- if (!c) {
236
- out(`${h.label} has no login command — skipping.`);
237
- continue;
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
- out(`\nSigning in to ${h.label} — follow its own prompts:`);
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
- for (const h of rows) out(` ${rowLine(h)}`);
246
- }
247
- out("");
248
-
249
- // 4 ── Browser for previews --------------------------------------
250
- // The preview warm-up and the agent's browser tools need a real
251
- // browser. System Chrome needs nothing; otherwise Playwright's Chromium
252
- // is downloaded once here (the daemon would do it lazily before the
253
- // first preview, which is a bad moment to wait for 150 MB).
254
- out("Step 4 — browser for previews:");
255
- try {
256
- const { ensurePreviewBrowser } = await import("./preview.mjs");
257
- const { createLogger } = await import("./daemon.mjs");
258
- let announced = false;
259
- const res = await ensurePreviewBrowser({
260
- log: (level, event, data) => {
261
- createLogger()(level, event, data);
262
- if (event === "browser.install.start" && !announced) {
263
- announced = true;
264
- out(" Downloading Chromium for previews (one time, ~150 MB)…");
265
- }
266
- },
267
- });
268
- if (res.browser === "chrome") out(" Using Google Chrome on this machine.");
269
- else if (res.ok) out(res.installed ? " Chromium installed." : " Bundled Chromium already installed.");
270
- else out(` No browser yet — previews will try again later (${res.error}).`);
271
- } catch (err) {
272
- out(` Skipped (${err?.message || err}).`);
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
  }