@gleapai/kai-bridge 0.10.2 → 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/README.md CHANGED
@@ -6,7 +6,7 @@ Claude Code / Codex login, and local dev-server previews.
6
6
  ```bash
7
7
  npm i -g @gleapai/kai-bridge # one install, so the background service has a stable path
8
8
  kai-bridge login # pair this machine with your Gleap account
9
- kai-bridge install # run at login (launchd / systemd / Task Scheduler)
9
+ kai-bridge service install # run at login (launchd / systemd / Task Scheduler)
10
10
  kai-bridge status
11
11
  kai-bridge ps # sessions running on this machine, with dashboard links (--json for tooling)
12
12
  ```
@@ -44,23 +44,38 @@ kai-bridge run --task "Explain the auth flow in this repo" --cwd ~/code/my-app
44
44
  `npm run sync-runner` copies the ACP runner from a sibling `gleap_code_analyzer` checkout;
45
45
  `npm test` runs the unit suite (uses real `git`).
46
46
 
47
+ ### Trying CLI changes locally
48
+
49
+ ```bash
50
+ npm run cli -- status # any kai-bridge command, run from this checkout
51
+ npm run cli -- login --name "dev laptop"
52
+ npm run cli -- start # foreground daemon; Ctrl-C stops it
53
+ npm run test:cli # fast CLI regression suite (test/cli.test.mjs)
54
+ ```
55
+
56
+ `npm run cli` (scripts/dev-cli.mjs) uses `<repo>/.kai-dev` as `KAI_HOME` and turns
57
+ self-update off, so it never touches `~/.kai` or the background service running the
58
+ released version. Export `KAI_API_BASE` / `KAI_APP_BASE` to point it at a local server.
59
+ Don't `npm link` the package: the login service runs the global install by absolute
60
+ path, and a link would swap that for this checkout.
61
+
47
62
 
48
63
  ### Computers and your own servers
49
64
 
50
65
  Kai Code offers **Kai Code Cloud** (Gleap's managed execution, using AI credits) or **Kai Code Bridge** (your coding agents on your computer or a server you manage, using your own AI subscriptions).
51
66
 
52
- Run `npm i -g @gleapai/kai-bridge && kai-bridge` in a local terminal or over SSH. The guided setup pairs the device, installs its background service and helps sign in to coding agents. Node.js 20+ and Git are required. On a Linux server, keep the user service running after logout using your system's service configuration; `kai-bridge doctor` reports service status.
67
+ Run `npm i -g @gleapai/kai-bridge && kai-bridge` in a local terminal or over SSH. The guided setup pairs the device, installs its background service and helps sign in to coding agents. Node.js 20+ and Git are required. On a Linux server, keep the user service running after logout using your system's service configuration; `kai-bridge service status` and `kai-bridge service logs --follow` show what it is doing.
53
68
 
54
69
  Codex and Claude Code ship with Kai Code Bridge. Missing agents can be installed or repaired, and existing agents can be updated independently:
55
70
 
56
71
  ```sh
57
72
  kai-bridge harness install codex
58
73
  kai-bridge harness update codex
59
- kai-bridge harness login codex --device-auth
74
+ kai-bridge profile login codex --device-auth
60
75
 
61
76
  kai-bridge harness install claude
62
77
  kai-bridge harness update claude
63
- kai-bridge harness login claude
78
+ kai-bridge profile login claude
64
79
  ```
65
80
 
66
81
  Updates install into `~/.kai/harnesses`, verify the new executable, then select it for subsequent sessions. A failed download or startup leaves the previous version selected. Existing versions are retained for running sessions; your global CLI installations and agent logins are not overwritten. `--version <version>` selects a specific package version (for Claude, the Claude Agent SDK package version).
@@ -1,23 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // kai-bridge — run Gleap Kai Code on this machine.
3
- //
4
- // kai-bridge setup guided onboarding: pair · background service · harness sign-ins
5
- // (a bare `kai-bridge` on an unpaired machine runs it too)
6
- // kai-bridge login | logout pair this device with your Gleap account / disconnect it
7
- // kai-bridge install | uninstall run at login (launchd / systemd / Task Scheduler)
8
- // kai-bridge start run in the foreground (what the service runs)
9
- // kai-bridge status device, service, profiles, repos
10
- // kai-bridge ps [--json] what runs right now: daemon, turns, dev servers — with dashboard links
11
- // kai-bridge harness list|install <id>|update <id>|login <id> [--device-auth] Claude Code + Codex are bundled; Cursor is downloaded
12
- // kai-bridge profile list|add <id> --harness claude|codex|cursor [--label …]|login <id>|remove <id>
13
- // kai-bridge repo scan|roots [add <dir>|remove <dir>]|primary <repoKey> <path>
14
- // kai-bridge run --task "…" [--model …] [--profile …] [--cwd …] (local turn, no server)
15
- // kai-bridge doctor
3
+ // The command reference lives in src/help.mjs (`kai-bridge help`).
16
4
 
17
5
  import { spawn } from "node:child_process";
18
- import { mkdtempSync } from "node:fs";
19
- import { tmpdir } from "node:os";
20
- import { dirname, join, resolve } from "node:path";
6
+ import { platform } from "node:os";
7
+ import { join, resolve } from "node:path";
21
8
  import { fileURLToPath } from "node:url";
22
9
 
23
10
  import { KAI_HOME, loadConfig, saveConfig } from "../src/config.mjs";
@@ -26,13 +13,38 @@ import { BridgeDaemon, createLogger, resolveProfiles } from "../src/daemon.mjs";
26
13
  import { runTurn } from "../src/executor.mjs";
27
14
  import { HARNESSES, createManagedProfile, describeProfiles, findBinary, loginCommand } from "../src/profiles.mjs";
28
15
  import { defaultRoots, groupByRepo, scanRoots, toDeviceRepoReport } from "../src/repos.mjs";
29
- import { install, isInstalled, uninstall, isEphemeralBinPath } from "../src/service.mjs";
16
+ import { install, isInstalled, uninstall, isEphemeralBinPath, servicePaths } from "../src/service.mjs";
17
+ import { followFile, tailFile } from "../src/logs.mjs";
30
18
  import { HARNESS_INFO, describeHarnesses, installHarness } from "../src/harnesses.mjs";
31
19
  import { fetchLatestVersion, installVersion, installedVersion, isNewer } from "../src/selfupdate.mjs";
32
- import { collectProcessList, formatProcessList } from "../src/ps.mjs";
20
+ import { collectProcessList, formatAge, formatProcessList } from "../src/ps.mjs";
21
+ import { renderCommandHelp, renderHelp, suggestCommand } from "../src/help.mjs";
22
+ import { palette } from "../src/tui.mjs";
33
23
 
34
24
  const BIN = fileURLToPath(import.meta.url);
25
+ // `npm i -g` only warns on an old Node; the first run would die with a
26
+ // SyntaxError instead. Say what is needed.
27
+ {
28
+ const major = Number(process.versions.node.split(".")[0]);
29
+ if (major < 20) {
30
+ process.stderr.write(`error: kai-bridge needs Node.js 20 or newer (this is ${process.versions.node}). Install a current Node from https://nodejs.org and run it again.\n`);
31
+ process.exit(1);
32
+ }
33
+ }
34
+ // `kai-bridge help | head` closes our stdout early: end quietly, not with a stack.
35
+ process.stdout.on("error", (err) => {
36
+ if (err?.code === "EPIPE") process.exit(0);
37
+ throw err;
38
+ });
35
39
  const argv = process.argv.slice(2);
40
+ // `--version`/`-v` and `--help`/`-h` are flags people type before or after the
41
+ // command; normalise them into the `version` and `help` commands.
42
+ if (argv.includes("--version") || argv.includes("-v") || argv[0] === "version") {
43
+ process.stdout.write(`${installedVersion()}\n`);
44
+ process.exit(0);
45
+ }
46
+ if (argv[0] === "--help" || argv[0] === "-h") argv[0] = "help";
47
+ else if (argv.includes("--help") || argv.includes("-h")) argv.splice(0, argv.length, "help", argv[0]);
36
48
  const cmd = argv[0];
37
49
  const flags = {};
38
50
  const positional = [];
@@ -52,14 +64,18 @@ const fail = (s) => {
52
64
  async function login() {
53
65
  const config = loadConfig();
54
66
  await pairDevice({ config, name: flags.name, print: out });
55
- out(`Next: \`kai-bridge install\` keeps it running at login. \`kai-bridge start\` runs it right now.`);
67
+ out(`Next: \`kai-bridge service install\` keeps it running at login (\`kai-bridge start\` runs it right now), then \`kai-bridge profile login claude\` (or codex/cursor) to sign in your coding agent. \`kai-bridge\` does all of this in one go.`);
56
68
  }
57
69
 
58
70
  async function status() {
59
71
  const config = loadConfig();
72
+ out(`node: ${process.version} (${process.execPath})`);
73
+ out(`git: ${findBinary("git") ?? "not found"}`);
74
+ out(`claude: ${findBinary("claude") ?? "not on PATH (bundled copy is used)"}`);
75
+ out(`codex: ${findBinary("codex") ?? "not on PATH (bundled copy is used)"}`);
60
76
  out(`home: ${KAI_HOME}`);
61
77
  out(`device: ${config.device ? `${config.device.name} (${config.device.id})` : "not paired — run `kai-bridge login`"}`);
62
- out(`service: ${isInstalled() ? "installed" : "not installed — run `kai-bridge install`"}`);
78
+ out(`service: ${isInstalled() ? "installed" : "not installed — run `kai-bridge service install`"}`);
63
79
  out("harnesses:");
64
80
  for (const h of describeHarnesses(KAI_HOME)) out(` ${h.id.padEnd(8)} ${h.installed ? `installed ${h.version ?? ""}` : "not installed — kai-bridge harness install " + h.id}${h.bundled ? " (bundled)" : ""}`);
65
81
  const profiles = await describeProfiles(resolveProfiles(config));
@@ -95,8 +111,10 @@ async function profile() {
95
111
  return;
96
112
  }
97
113
  if (sub === "login") {
98
- const p = resolveProfiles(config).find((x) => x.id === positional[1]);
99
- if (!p) fail("unknown profile");
114
+ // A harness name means the machine's own login for it.
115
+ const wanted = HARNESS_INFO[positional[1]] ? `${positional[1]}-ambient` : positional[1];
116
+ const p = resolveProfiles(config).find((x) => x.id === wanted);
117
+ if (!p) fail(`unknown profile ${positional[1] ?? ""} — \`kai-bridge profile list\` shows them; \`profile login claude|codex|cursor\` signs in the machine's own agent.`);
100
118
  await installHarness(p.harness, { kaiHome: KAI_HOME, onLog: out });
101
119
  const c = loginCommand(p.harness, p.configDir, KAI_HOME, flags["device-auth"] ? { deviceAuth: true } : undefined);
102
120
  if (!c) fail(`${p.harness} is not installed`);
@@ -110,7 +128,7 @@ async function profile() {
110
128
  out("removed (the login dir is kept under ~/.kai/accounts)");
111
129
  return;
112
130
  }
113
- fail("usage: profile list|add|login|remove");
131
+ fail("usage: profile list | login <id> [--device-auth] | add <id> --harness <claude|codex|cursor> [--label …] | remove <id>");
114
132
  }
115
133
 
116
134
  async function harness() {
@@ -120,25 +138,22 @@ async function harness() {
120
138
  return;
121
139
  }
122
140
  const id = positional[1];
123
- if (!HARNESS_INFO[id]) fail("usage: harness list|install <id>|update <id>|login <id> (claude|codex|cursor)");
141
+ if (!HARNESS_INFO[id]) fail("usage: harness list | install <id> | update <id> (claude|codex|cursor — sign in with `profile login <id>`)");
124
142
  if (sub === "install" || sub === "update") {
125
143
  const res = await installHarness(id, { kaiHome: KAI_HOME, onLog: out, update: sub === "update", version: typeof flags.version === "string" ? flags.version : undefined });
126
144
  if (!res.ok) fail(`${id} is not usable`);
127
145
  out(`${HARNESS_INFO[id].label} ${res.version} → ${res.binary}`);
128
146
  return;
129
147
  }
130
- if (sub === "login") {
131
- positional[1] = `${id}-ambient`;
132
- positional[0] = "login";
133
- return profile();
134
- }
135
- fail("usage: harness list|install <id>|update <id>|login <id> [--device-auth]");
148
+ if (sub === "login") return profile(); // old spelling of `profile login <id>`
149
+ fail("usage: harness list | install <id> | update <id>");
136
150
  }
137
151
 
138
152
  async function repo() {
139
153
  const config = loadConfig();
140
- const sub = positional[0] || "scan";
154
+ const sub = positional[0] || "roots";
141
155
  if (sub === "scan") {
156
+ // Undocumented: `status` shows the same list.
142
157
  const groups = groupByRepo(scanRoots([...defaultRoots(), ...(config.roots || [])]), config.primaryOverrides);
143
158
  for (const r of toDeviceRepoReport(groups)) out(`${r.key}\t${r.primaryPath}\t${r.branch}`);
144
159
  return;
@@ -160,7 +175,7 @@ async function repo() {
160
175
  out("ok");
161
176
  return;
162
177
  }
163
- fail("usage: repo scan|roots [add|remove <dir>]|primary <repoKey> <path>");
178
+ fail("usage: repo roots [add <dir> | remove <dir>] | primary <repoKey> <path> (`kai-bridge status` lists the checkouts)");
164
179
  }
165
180
 
166
181
  async function runLocal() {
@@ -187,13 +202,54 @@ async function runLocal() {
187
202
  process.exit(res.code ?? 0);
188
203
  }
189
204
 
190
- async function doctor() {
191
- out(`node: ${process.version}`);
192
- out(`claude: ${findBinary("claude") ?? "not found (npm i -g @anthropic-ai/claude-code)"}`);
193
- out(`codex: ${findBinary("codex") ?? "not found (npm i -g @openai/codex)"}`);
194
- out(`git: ${findBinary("git") ?? "not found"}`);
195
- out(`home: ${KAI_HOME}`);
196
- await status();
205
+ /** `kai-bridge service …` — the login service and its logs. */
206
+ async function service() {
207
+ const sub = positional[0] || "status";
208
+ if (sub === "install") {
209
+ if (isEphemeralBinPath(BIN)) {
210
+ fail(
211
+ "Running from npx, so there's no stable path to install from.\n" +
212
+ "Install it once, then this works:\n\n" +
213
+ " npm i -g @gleapai/kai-bridge\n kai-bridge service install\n",
214
+ );
215
+ }
216
+ const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
217
+ out(`Installed (${res.kind}): ${res.path}. It runs at login and restarts if it crashes.`);
218
+ return;
219
+ }
220
+ if (sub === "uninstall") {
221
+ out(uninstall() ? "Removed." : "Nothing to remove.");
222
+ return;
223
+ }
224
+ if (sub === "restart") {
225
+ if (!isInstalled()) fail("The background service is not installed — run `kai-bridge service install`.");
226
+ const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
227
+ out(`Restarted (${res.kind}).`);
228
+ return;
229
+ }
230
+ if (sub === "status") {
231
+ const paths = servicePaths();
232
+ const unit = platform() === "darwin" ? paths.launchdPlist : platform() === "linux" ? paths.systemdUnit : "Task Scheduler: GleapKaiBridge";
233
+ const d = collectProcessList().daemon;
234
+ out(`service: ${isInstalled() ? `installed · ${unit}` : "not installed — run `kai-bridge service install`"}`);
235
+ out(`daemon: ${d.running ? `running · pid ${d.pid} · up ${formatAge(d.startedAt)} · v${d.version}` : `not running${d.pid ? ` (stale lock, pid ${d.pid})` : ""}`}`);
236
+ out(`logs: ${join(KAI_HOME, "logs", "bridge.log")}`);
237
+ return;
238
+ }
239
+ if (sub === "logs") {
240
+ const file = join(KAI_HOME, "logs", flags.service ? "service.err.log" : "bridge.log");
241
+ const n = Number(flags.lines) || 50;
242
+ const last = tailFile(file, n);
243
+ if (last) out(last);
244
+ else if (!flags.follow) out(`(no log yet at ${file})`);
245
+ if (flags.follow) {
246
+ const ctrl = new AbortController();
247
+ process.on("SIGINT", () => ctrl.abort());
248
+ await followFile(file, { signal: ctrl.signal });
249
+ }
250
+ return;
251
+ }
252
+ fail("usage: service install | uninstall | restart | status | logs [--lines <n>] [--follow] [--service]");
197
253
  }
198
254
 
199
255
  try {
@@ -207,20 +263,14 @@ try {
207
263
  case "logout":
208
264
  await logout();
209
265
  break;
210
- case "install": {
211
- if (isEphemeralBinPath(BIN)) {
212
- fail(
213
- "Running from npx, so there's no stable path to install from.\n" +
214
- "Install it once, then this works:\n\n" +
215
- " npm i -g @gleapai/kai-bridge\n kai-bridge install\n",
216
- );
217
- }
218
- const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
219
- out(`Installed (${res.kind}): ${res.path}. It runs at login and restarts if it crashes.`);
266
+ case "service":
267
+ await service();
220
268
  break;
221
- }
269
+ case "install":
222
270
  case "uninstall":
223
- out(uninstall() ? "Removed." : "Nothing to remove.");
271
+ // Old spellings, kept working.
272
+ positional.unshift(cmd);
273
+ await service();
224
274
  break;
225
275
  case "start": {
226
276
  process.env.KAI_BRIDGE_FOREGROUND = process.stdout.isTTY ? "1" : "0";
@@ -255,7 +305,7 @@ try {
255
305
  await runLocal();
256
306
  break;
257
307
  case "doctor":
258
- await doctor();
308
+ await status();
259
309
  break;
260
310
  case "update": {
261
311
  // Manual update: ignores autoUpdate and a previous failed attempt.
@@ -288,14 +338,20 @@ try {
288
338
  await runSetup({ binPath: BIN });
289
339
  break;
290
340
  }
291
- out(`kai-bridge — run Gleap Kai Code on this machine
292
-
293
- setup guided onboarding (pair · service · sign-ins)
294
- login | logout | install | uninstall | start | status | ps | doctor | update
295
- harness list|install <id>|update <id>|login <id> [--device-auth]
296
- profile list|add <id> --harness claude|codex|cursor|login <id>|remove <id>
297
- repo scan|roots [add|remove <dir>]|primary <repoKey> <path>
298
- run --task "…" [--model …] [--profile …] [--cwd …]`);
341
+ if (cmd === "help" || !cmd) {
342
+ const topic = positional[0];
343
+ if (topic) {
344
+ const lines = renderCommandHelp(topic, { paint: palette() });
345
+ if (!lines) fail(`unknown command: ${topic}${suggestCommand(topic) ? ` (did you mean \`${suggestCommand(topic)}\`?)` : ""}\nRun \`kai-bridge help\` to list the commands.`);
346
+ for (const l of lines) out(l);
347
+ } else {
348
+ for (const l of renderHelp({ paint: palette(), version: installedVersion() })) out(l);
349
+ }
350
+ break;
351
+ }
352
+ // Not a command at all. A close match is almost always a typo.
353
+ const near = suggestCommand(cmd);
354
+ fail(`unknown command: ${cmd}${near ? ` (did you mean \`${near}\`?)` : ""}\nRun \`kai-bridge help\` to list the commands.`);
299
355
  }
300
356
  }
301
357
  } catch (err) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@gleapai/kai-bridge",
9
- "version": "0.10.2",
9
+ "version": "0.11.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -134,9 +134,6 @@
134
134
  "cpu": [
135
135
  "arm64"
136
136
  ],
137
- "libc": [
138
- "glibc"
139
- ],
140
137
  "license": "SEE LICENSE IN LICENSE.md",
141
138
  "optional": true,
142
139
  "os": [
@@ -150,9 +147,6 @@
150
147
  "cpu": [
151
148
  "arm64"
152
149
  ],
153
- "libc": [
154
- "musl"
155
- ],
156
150
  "license": "SEE LICENSE IN LICENSE.md",
157
151
  "optional": true,
158
152
  "os": [
@@ -166,9 +160,6 @@
166
160
  "cpu": [
167
161
  "x64"
168
162
  ],
169
- "libc": [
170
- "glibc"
171
- ],
172
163
  "license": "SEE LICENSE IN LICENSE.md",
173
164
  "optional": true,
174
165
  "os": [
@@ -182,9 +173,6 @@
182
173
  "cpu": [
183
174
  "x64"
184
175
  ],
185
- "libc": [
186
- "musl"
187
- ],
188
176
  "license": "SEE LICENSE IN LICENSE.md",
189
177
  "optional": true,
190
178
  "os": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "Kai Code Bridge runs Kai Code on your computer or server with your own coding subscriptions and local previews.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,9 @@
25
25
  "postinstall": "node scripts/postinstall.mjs",
26
26
  "runtime:check": "node scripts/runtime-smoke.mjs",
27
27
  "prepack": "node scripts/prepare-release.mjs",
28
- "postpack": "node scripts/prepare-release.mjs --clean"
28
+ "postpack": "node scripts/prepare-release.mjs --clean",
29
+ "cli": "node scripts/dev-cli.mjs",
30
+ "test:cli": "node --test test/cli.test.mjs"
29
31
  },
30
32
  "dependencies": {
31
33
  "@agentclientprotocol/claude-agent-acp": "0.81.1",
package/src/config.mjs CHANGED
@@ -31,6 +31,7 @@ export function defaultConfig() {
31
31
  appBase: DEFAULT_APP_BASE,
32
32
  realtime: DEFAULT_REALTIME,
33
33
  device: null, // { id, name, token, organisationId, userId }
34
+ machineId: null, // stable per-install id; re-pairing keeps the same device row
34
35
  roots: [], // extra scan roots
35
36
  profiles: [
36
37
  { id: "claude-ambient", harness: "claude", kind: "ambient", label: "Claude (this machine)" },
package/src/help.mjs ADDED
@@ -0,0 +1,115 @@
1
+ // The command reference: one table drives `kai-bridge help`, `help <command>`,
2
+ // the "unknown command" hint and the usage lines the commands print themselves.
3
+
4
+ import { palette } from "./tui.mjs";
5
+
6
+ export const COMMANDS = [
7
+ // ── setup and account ──
8
+ { name: "setup", group: "Setup and account", usage: "setup", summary: "guided onboarding: pair, service, agent sign-ins",
9
+ details: ["Re-run it any time: it shows the machine's state and offers what is left to do."] },
10
+ { name: "login", group: "Setup and account", usage: "login [--name <device name>]", brief: "login [--name …]", summary: "pair this machine with your Gleap account",
11
+ details: ["Opens a link to approve in the dashboard. `--name` sets how the machine appears there."] },
12
+ { name: "logout", group: "Setup and account", usage: "logout", summary: "disconnect this machine, remove its service",
13
+ details: ["Your Claude Code / Codex / Cursor logins stay on the machine."] },
14
+ // ── service ──
15
+ { name: "service", group: "Background service", usage: "service install | uninstall | restart | status | logs [--lines <n>] [--follow]", brief: "service <action>",
16
+ summary: "the bridge that runs at login and restarts after crashes",
17
+ details: [
18
+ "install add it (launchd on macOS, systemd --user on Linux, Task Scheduler on Windows) and start it",
19
+ "uninstall stop and remove it",
20
+ "restart reload it — after a manual `npm i -g`, or when it looks stuck",
21
+ "status installed? running? since when? where are the logs?",
22
+ "logs the last 50 lines of the bridge log; `--follow` keeps streaming, `--lines 200` shows more",
23
+ ],
24
+ examples: ["kai-bridge service logs --follow", "kai-bridge service restart"] },
25
+ { name: "start", group: "Background service", usage: "start", summary: "run in the foreground (what the service runs)",
26
+ details: ["Ctrl-C stops it. Only one bridge runs per machine: stop the service first, or use `kai-bridge service install`."] },
27
+ { name: "install", hidden: true, aliasOf: "service", usage: "install", summary: "same as `service install`" },
28
+ { name: "uninstall", hidden: true, aliasOf: "service", usage: "uninstall", summary: "same as `service uninstall`" },
29
+ // ── inspect ──
30
+ { name: "status", group: "Inspect", usage: "status", summary: "tools, device, service, agents, profiles, repos",
31
+ details: ["Node, git and agent binaries first, then the machine's pairing, service, sign-ins and the repos Kai can see."] },
32
+ { name: "ps", group: "Inspect", usage: "ps [--json]", summary: "running turns and dev servers, with links" },
33
+ { name: "doctor", hidden: true, aliasOf: "status", usage: "doctor", summary: "same as `status`" },
34
+ // ── agents ──
35
+ { name: "harness", group: "Coding agents", usage: "harness list | install <id> | update <id>", brief: "harness <action> [id]",
36
+ summary: "install or update Claude Code, Codex and Cursor",
37
+ details: ["<id> is claude, codex or cursor. Claude Code and Codex ship bundled; Cursor is downloaded on install.", "Signing in is `kai-bridge profile login <id>`."],
38
+ examples: ["kai-bridge harness update codex", "kai-bridge harness install cursor"] },
39
+ { name: "profile", group: "Coding agents", usage: "profile list | login <id> [--device-auth] | add <id> --harness <claude|codex|cursor> [--label …] | remove <id>", brief: "profile <action> [id]",
40
+ summary: "sign in to agents, and keep extra logins apart",
41
+ details: [
42
+ "`login claude` (or codex, cursor) signs in the machine's own agent. `--device-auth` uses a code instead of a browser (servers, SSH).",
43
+ "`add` creates a second, separate login — another Claude account, a shared team Codex — that sessions can pick.",
44
+ ],
45
+ examples: ["kai-bridge profile login codex --device-auth", "kai-bridge profile add work --harness claude --label \"Claude (work)\"", "kai-bridge profile login work"] },
46
+ // ── repos ──
47
+ { name: "repo", group: "Repositories", usage: "repo roots [add <dir> | remove <dir>] | primary <repoKey> <path>", brief: "repo <action> […]",
48
+ summary: "where Kai looks for checkouts, and the primary per repo",
49
+ details: ["Roots are scanned for git checkouts (`kai-bridge status` lists what was found). `primary` picks the checkout used when a repo appears more than once."],
50
+ examples: ["kai-bridge repo roots add ~/work", "kai-bridge repo primary github.com/acme/app ~/work/app"] },
51
+ // ── other ──
52
+ { name: "update", group: "Advanced", usage: "update", summary: "install the latest version now",
53
+ details: ["The background service updates itself; this forces it, also after a failed attempt."] },
54
+ { name: "help", group: "Advanced", usage: "help [command]", summary: "this list, or one command in detail" },
55
+ { name: "run", hidden: true, usage: "run --task \"…\" [--model <id>] [--profile <id>] [--cwd <dir>]", summary: "one local turn without the server (for development)",
56
+ examples: ["kai-bridge run --task \"Explain the auth flow\" --cwd ~/code/app"] },
57
+ ];
58
+
59
+ export const findCommand = (name) => COMMANDS.find((c) => c.name === name) ?? null;
60
+
61
+ /** `kai-bridge help` — grouped, one line per command. */
62
+ export function renderHelp({ paint = palette(), version } = {}) {
63
+ const p = paint;
64
+ const lines = [`${p.bold("kai-bridge")}${version ? p.dim(` v${version}`) : ""} ${p.dim("— run Gleap Kai Code on this machine")}`, ""];
65
+ const shown = COMMANDS.filter((c) => !c.hidden);
66
+ const groups = [...new Set(shown.map((c) => c.group))];
67
+ const brief = (c) => c.brief ?? c.usage;
68
+ const w = Math.max(...shown.map((c) => brief(c).length)) + 2;
69
+ for (const g of groups) {
70
+ lines.push(p.dim(g));
71
+ for (const c of shown.filter((c) => c.group === g)) {
72
+ const args = brief(c).slice(c.name.length);
73
+ lines.push(` ${p.magenta(c.name)}${p.dim(args)}${" ".repeat(w - brief(c).length)}${c.summary}`);
74
+ }
75
+ lines.push("");
76
+ }
77
+ lines.push(p.dim("kai-bridge help <command> shows details · --version prints the version"));
78
+ return lines;
79
+ }
80
+
81
+ /** `kai-bridge help <command>`. Null when the command does not exist. */
82
+ export function renderCommandHelp(name, { paint = palette() } = {}) {
83
+ let c = findCommand(name);
84
+ if (!c) return null;
85
+ const p = paint;
86
+ const lines = [];
87
+ if (c.aliasOf) {
88
+ lines.push(p.dim(`\`kai-bridge ${c.usage}\` is ${c.summary.replace(/^same as /, "the same as ")}.`), "");
89
+ c = findCommand(c.aliasOf);
90
+ }
91
+ lines.push(`${p.bold(`kai-bridge ${c.usage}`)}`, "", ` ${c.summary}`);
92
+ if (c.details?.length) {
93
+ lines.push("");
94
+ for (const d of c.details) lines.push(` ${d}`);
95
+ }
96
+ if (c.examples?.length) {
97
+ lines.push("", p.dim(" Examples"));
98
+ for (const e of c.examples) lines.push(` ${e}`);
99
+ }
100
+ return lines;
101
+ }
102
+
103
+ /** The closest known command for a typo, or null when nothing is close. */
104
+ export function suggestCommand(input) {
105
+ const s = String(input || "").toLowerCase();
106
+ if (!s) return null;
107
+ const dist = (a, b) => {
108
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
109
+ for (let j = 1; j <= b.length; j += 1) d[0][j] = j;
110
+ for (let i = 1; i <= a.length; i += 1) for (let j = 1; j <= b.length; j += 1) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
111
+ return d[a.length][b.length];
112
+ };
113
+ const best = COMMANDS.filter((c) => !c.hidden).map((c) => ({ name: c.name, d: dist(s, c.name) })).sort((a, b) => a.d - b.d)[0];
114
+ return best && (best.d <= 2 || best.name.startsWith(s)) ? best.name : null;
115
+ }
package/src/logs.mjs ADDED
@@ -0,0 +1,62 @@
1
+ // `kai-bridge service logs`: the last lines of a log file, optionally
2
+ // followed. Plain fs — the daemon appends lines, nothing rotates them.
3
+
4
+ import { closeSync, existsSync, openSync, readSync, statSync, watchFile, unwatchFile } from "node:fs";
5
+
6
+ /** The last `lines` lines of `path` ("" when the file is missing or empty). */
7
+ export function tailFile(path, lines = 50) {
8
+ if (!existsSync(path)) return "";
9
+ const size = statSync(path).size;
10
+ if (size === 0) return "";
11
+ const fd = openSync(path, "r");
12
+ try {
13
+ // Read backwards in chunks until enough newlines are in hand.
14
+ const chunk = 64 * 1024;
15
+ let pos = size;
16
+ let buf = "";
17
+ while (pos > 0 && buf.split("\n").length <= lines + 1) {
18
+ const len = Math.min(chunk, pos);
19
+ pos -= len;
20
+ const b = Buffer.alloc(len);
21
+ readSync(fd, b, 0, len, pos);
22
+ buf = b.toString("utf8") + buf;
23
+ }
24
+ const all = buf.replace(/\n$/, "").split("\n");
25
+ return all.slice(-lines).join("\n");
26
+ } finally {
27
+ closeSync(fd);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Print `path` as it grows. Resolves only when `signal` aborts (Ctrl-C).
33
+ * Polls via fs.watchFile: works on every platform and for files that do
34
+ * not exist yet (the daemon may start after `logs --follow`).
35
+ */
36
+ export function followFile(path, { write = (s) => process.stdout.write(s), signal, intervalMs = 500 } = {}) {
37
+ let pos = existsSync(path) ? statSync(path).size : 0;
38
+ const drain = () => {
39
+ if (!existsSync(path)) return;
40
+ const size = statSync(path).size;
41
+ if (size < pos) pos = 0; // truncated or replaced
42
+ if (size === pos) return;
43
+ const fd = openSync(path, "r");
44
+ try {
45
+ const b = Buffer.alloc(size - pos);
46
+ readSync(fd, b, 0, b.length, pos);
47
+ write(b.toString("utf8"));
48
+ pos = size;
49
+ } finally {
50
+ closeSync(fd);
51
+ }
52
+ };
53
+ return new Promise((resolve) => {
54
+ watchFile(path, { interval: intervalMs }, drain);
55
+ const stop = () => {
56
+ unwatchFile(path, drain);
57
+ resolve();
58
+ };
59
+ if (signal?.aborted) stop();
60
+ else signal?.addEventListener("abort", stop, { once: true });
61
+ });
62
+ }
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";