@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/src/deps.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // yet) we leave the directory absent and the install path takes over with
12
12
  // the lockfile-driven, cache-first command instead of a bare `npm install`.
13
13
 
14
- import { execFileSync } from "node:child_process";
14
+ import { spawn } from "node:child_process";
15
15
  import { createHash } from "node:crypto";
16
16
  import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
17
17
  import { platform } from "node:os";
@@ -84,24 +84,45 @@ export function canSeedNodeModules({ primaryPath, cwd }) {
84
84
  return { ok: true, reason: "lockfile_match", pm: b.pm };
85
85
  }
86
86
 
87
+ /**
88
+ * `cmd args` as a promise, output discarded (stderr's head kept for the
89
+ * reason). Asynchronous on purpose: cloning a big `node_modules` takes 30 s
90
+ * to 2.5 min (bridge.log: 144 s for the Gleap dashboard, 105 s for the
91
+ * Server), and a synchronous copy froze the daemon for all of it.
92
+ */
93
+ function runQuiet(cmd, args) {
94
+ return new Promise((resolve, reject) => {
95
+ const child = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
96
+ let stderr = "";
97
+ child.stderr.on("data", (chunk) => {
98
+ if (stderr.length < 2000) stderr += chunk;
99
+ });
100
+ child.on("error", reject);
101
+ child.on("close", (code, signal) => {
102
+ if (code === 0) resolve();
103
+ else reject(new Error(`${cmd} exited with ${code ?? signal}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`));
104
+ });
105
+ });
106
+ }
107
+
87
108
  /**
88
109
  * Copy the primary checkout's `node_modules` into a fresh worktree when
89
- * the lockfiles match. Returns `{ seeded, reason, ms }`; never throws — a
110
+ * the lockfiles match. Resolves `{ seeded, reason, ms }`; never rejects — a
90
111
  * failed or partial copy is removed so the install path still runs.
91
112
  *
92
113
  * macOS `cp -c` clones via clonefile(2) and falls back to a plain copy on
93
114
  * filesystems without it; `-R` without `-L` keeps symlinks as symlinks
94
115
  * (`.bin` shims, pnpm's store links — all relative, so they stay valid).
95
116
  */
96
- export function seedNodeModules({ primaryPath, cwd, os = platform(), exec = execFileSync }) {
117
+ export async function seedNodeModules({ primaryPath, cwd, os = platform(), exec = runQuiet }) {
97
118
  const started = Date.now();
98
119
  const check = canSeedNodeModules({ primaryPath, cwd });
99
120
  if (!check.ok) return { seeded: false, reason: check.reason, ms: 0 };
100
121
  const src = join(primaryPath, "node_modules");
101
122
  const dst = join(cwd, "node_modules");
102
123
  try {
103
- if (os === "darwin") exec("cp", ["-c", "-R", src, dst], { stdio: "ignore" });
104
- else if (os === "linux") exec("cp", ["-R", "--reflink=auto", src, dst], { stdio: "ignore" });
124
+ if (os === "darwin") await exec("cp", ["-c", "-R", src, dst]);
125
+ else if (os === "linux") await exec("cp", ["-R", "--reflink=auto", src, dst]);
105
126
  else return { seeded: false, reason: "unsupported_platform", ms: 0 };
106
127
  return { seeded: true, reason: check.reason, ms: Date.now() - started };
107
128
  } catch (err) {
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";