@gleapai/kai-bridge 0.2.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 +45 -0
- package/bin/kai-bridge.mjs +275 -0
- package/package.json +47 -0
- package/runner/acp-runner.mjs +671 -0
- package/runner/lib/acp/harnesses.mjs +342 -0
- package/runner/lib/acp/mapper.mjs +575 -0
- package/runner/lib/acp/transcripts.mjs +238 -0
- package/runner/lib/contract.mjs +1122 -0
- package/runner/lib/wire-proxy.mjs +200 -0
- package/runner/personas/claude/kai-asker.md +69 -0
- package/runner/personas/claude/kai-doc-explorer.md +130 -0
- package/runner/personas/claude/kai-documentarian.md +205 -0
- package/runner/personas/claude/kai-researcher.md +68 -0
- package/runner/personas/claude/kai-resolution-analyst.md +164 -0
- package/runner/personas/codex/kai-asker.md +68 -0
- package/runner/personas/codex/kai-doc-explorer.md +130 -0
- package/runner/personas/codex/kai-documentarian.md +211 -0
- package/runner/personas/codex/kai-researcher.md +67 -0
- package/runner/personas/codex/kai-resolution-analyst.md +164 -0
- package/runner/tools/ask-user-mcp.mjs +130 -0
- package/runner/tools/todo-mcp.mjs +116 -0
- package/scripts/postinstall.mjs +24 -0
- package/src/api.mjs +141 -0
- package/src/config.mjs +76 -0
- package/src/daemon.mjs +904 -0
- package/src/executor.mjs +156 -0
- package/src/harnesses.mjs +252 -0
- package/src/preview.mjs +337 -0
- package/src/profiles.mjs +250 -0
- package/src/repos.mjs +182 -0
- package/src/service.mjs +162 -0
- package/src/setup.mjs +261 -0
- package/src/workspace.mjs +175 -0
package/src/repos.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Repo discovery + identity.
|
|
2
|
+
//
|
|
3
|
+
// The bridge never asks the user to map paths by hand for the common
|
|
4
|
+
// case: it scans a few conventional roots (plus any the user adds), reads
|
|
5
|
+
// each checkout's `origin`, and normalises it to `host/owner/name` so the
|
|
6
|
+
// Server can match it to the org's connected repositories (or offer it as
|
|
7
|
+
// a local-only repo — any git host works). When the same repo is checked
|
|
8
|
+
// out more than once, a PRIMARY is chosen (default-branch checkout wins,
|
|
9
|
+
// then most recently touched) and can be overridden in settings.
|
|
10
|
+
//
|
|
11
|
+
// Pure functions + one fs walker; tests feed synthetic inputs.
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join, basename, dirname } from "node:path";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_ROOT_NAMES = ["Documents", "code", "dev", "Projects", "projects", "src", "work", "repos", "git"];
|
|
19
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "Library", "Applications", ".Trash", "vendor", "dist", "build", ".cache"]);
|
|
20
|
+
export const MAX_SCAN_DEPTH = 3;
|
|
21
|
+
|
|
22
|
+
export function defaultRoots(home = homedir()) {
|
|
23
|
+
return DEFAULT_ROOT_NAMES.map((n) => join(home, n)).filter((p) => existsSync(p));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `git@github.com:Gleap/Server.git`, `https://github.com/Gleap/Server`,
|
|
28
|
+
* `ssh://git@bitbucket.org/team/repo.git`, `https://user@dev.azure.com/org/proj/_git/repo`
|
|
29
|
+
* → `{ host, owner, name, key: "host/owner/name" }` (lower-cased, `.git` stripped).
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeRemote(remote) {
|
|
32
|
+
const raw = String(remote || "").trim();
|
|
33
|
+
if (!raw) return null;
|
|
34
|
+
let host = "";
|
|
35
|
+
let path = "";
|
|
36
|
+
// Local / file remotes (bare repos on disk, tests): host "local".
|
|
37
|
+
if (raw.startsWith("/") || raw.startsWith("file://")) {
|
|
38
|
+
const p = raw.replace(/^file:\/\//, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
39
|
+
const segs = p.split("/").filter(Boolean);
|
|
40
|
+
if (segs.length < 1) return null;
|
|
41
|
+
const name = segs.pop().toLowerCase();
|
|
42
|
+
const owner = (segs.pop() || "root").toLowerCase();
|
|
43
|
+
return { host: "local", owner, name, key: `local/${owner}/${name}` };
|
|
44
|
+
}
|
|
45
|
+
const scp = /^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)(.+)$/.exec(raw);
|
|
46
|
+
if (scp && !raw.includes("://")) {
|
|
47
|
+
host = scp[1];
|
|
48
|
+
path = scp[2];
|
|
49
|
+
} else {
|
|
50
|
+
try {
|
|
51
|
+
const u = new URL(raw);
|
|
52
|
+
host = u.hostname;
|
|
53
|
+
path = u.pathname;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
|
|
59
|
+
// Azure DevOps: org/project/_git/repo → keep org/project as "owner".
|
|
60
|
+
path = path.replace(/\/_git\//, "/");
|
|
61
|
+
const segments = path.split("/").filter(Boolean);
|
|
62
|
+
if (segments.length < 2) return null;
|
|
63
|
+
const name = segments.pop();
|
|
64
|
+
const owner = segments.join("/");
|
|
65
|
+
const hostLc = host.toLowerCase();
|
|
66
|
+
return { host: hostLc, owner: owner.toLowerCase(), name: name.toLowerCase(), key: `${hostLc}/${owner.toLowerCase()}/${name.toLowerCase()}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function git(cwd, args) {
|
|
70
|
+
try {
|
|
71
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
72
|
+
} catch {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Inspect one checkout. Returns null when it isn't a git repo with an origin. */
|
|
78
|
+
export function inspectCheckout(dir) {
|
|
79
|
+
if (!existsSync(join(dir, ".git"))) return null;
|
|
80
|
+
const remote = git(dir, ["remote", "get-url", "origin"]);
|
|
81
|
+
const id = normalizeRemote(remote);
|
|
82
|
+
if (!id) return null;
|
|
83
|
+
const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]) || "HEAD";
|
|
84
|
+
const defaultRef = git(dir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]); // origin/main
|
|
85
|
+
const defaultBranch = defaultRef ? defaultRef.replace(/^origin\//, "") : "";
|
|
86
|
+
const dirtyCount = git(dir, ["status", "--porcelain"]).split("\n").filter(Boolean).length;
|
|
87
|
+
const lastTouched = (() => {
|
|
88
|
+
try {
|
|
89
|
+
return statSync(join(dir, ".git", "index")).mtimeMs;
|
|
90
|
+
} catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
return { path: dir, remote, ...id, branch, defaultBranch, dirtyCount, lastTouched };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Walk roots for git checkouts (depth-limited, skips dependency dirs). */
|
|
98
|
+
export function scanRoots(roots, { maxDepth = MAX_SCAN_DEPTH, inspect = inspectCheckout } = {}) {
|
|
99
|
+
const found = [];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const walk = (dir, depth) => {
|
|
102
|
+
if (seen.has(dir)) return;
|
|
103
|
+
seen.add(dir);
|
|
104
|
+
const info = inspect(dir);
|
|
105
|
+
if (info) {
|
|
106
|
+
found.push(info);
|
|
107
|
+
return; // nested repos inside a repo are submodules/worktrees — skip
|
|
108
|
+
}
|
|
109
|
+
if (depth >= maxDepth) return;
|
|
110
|
+
let entries;
|
|
111
|
+
try {
|
|
112
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const e of entries) {
|
|
117
|
+
if (!e.isDirectory() || e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
|
|
118
|
+
walk(join(dir, e.name), depth + 1);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
for (const root of roots) walk(root, 0);
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Group checkouts by repo key and pick the primary: an explicit override,
|
|
127
|
+
* else the checkout sitting on the default branch, else the most recently
|
|
128
|
+
* touched. Returns `[{ key, host, owner, name, primary, checkouts[] }]`.
|
|
129
|
+
*/
|
|
130
|
+
export function groupByRepo(checkouts, overrides = {}) {
|
|
131
|
+
const groups = new Map();
|
|
132
|
+
for (const c of checkouts) {
|
|
133
|
+
const g = groups.get(c.key) ?? { key: c.key, host: c.host, owner: c.owner, name: c.name, checkouts: [] };
|
|
134
|
+
g.checkouts.push(c);
|
|
135
|
+
groups.set(c.key, g);
|
|
136
|
+
}
|
|
137
|
+
for (const g of groups.values()) {
|
|
138
|
+
const override = overrides[g.key];
|
|
139
|
+
const byOverride = override ? g.checkouts.find((c) => c.path === override) : null;
|
|
140
|
+
const onDefault = g.checkouts.filter((c) => c.defaultBranch && c.branch === c.defaultBranch);
|
|
141
|
+
const newest = (list) => [...list].sort((a, b) => b.lastTouched - a.lastTouched)[0];
|
|
142
|
+
g.primary = byOverride ?? (onDefault.length > 0 ? newest(onDefault) : newest(g.checkouts));
|
|
143
|
+
}
|
|
144
|
+
return [...groups.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What the Server sees in `hello` / `repos.changed`. Paths stay on the device — only the primary path is sent, for display in the owner's own settings. */
|
|
148
|
+
export function toDeviceRepoReport(groups) {
|
|
149
|
+
return groups.map((g) => ({
|
|
150
|
+
key: g.key,
|
|
151
|
+
host: g.host,
|
|
152
|
+
owner: g.owner,
|
|
153
|
+
name: g.name,
|
|
154
|
+
primaryPath: g.primary.path,
|
|
155
|
+
branch: g.primary.branch,
|
|
156
|
+
defaultBranch: g.primary.defaultBranch || null,
|
|
157
|
+
dirtyCount: g.primary.dirtyCount,
|
|
158
|
+
checkouts: g.checkouts.length,
|
|
159
|
+
label: basename(g.primary.path),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Where a "Clone here" should land: the directory most of the machine's
|
|
165
|
+
* repos already live in — cloning next to the user's other checkouts is
|
|
166
|
+
* what "here" means to them. A bare scan root is only the fallback for a
|
|
167
|
+
* machine with no repos yet ("~/Documents" is technically the root that
|
|
168
|
+
* discovered "~/Documents/Gleap/*", but nobody wants a clone in
|
|
169
|
+
* ~/Documents when 30 repos sit in ~/Documents/Gleap).
|
|
170
|
+
*/
|
|
171
|
+
export function preferredCloneRoot(groups, fallback) {
|
|
172
|
+
const counts = new Map();
|
|
173
|
+
for (const g of groups || []) {
|
|
174
|
+
const parent = g?.primary?.path ? dirname(g.primary.path) : null;
|
|
175
|
+
if (parent) counts.set(parent, (counts.get(parent) ?? 0) + 1);
|
|
176
|
+
}
|
|
177
|
+
let best = null;
|
|
178
|
+
for (const [dir, n] of counts) {
|
|
179
|
+
if (!best || n > best.n) best = { dir, n };
|
|
180
|
+
}
|
|
181
|
+
return best?.dir ?? fallback ?? null;
|
|
182
|
+
}
|
package/src/service.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Login-item service: launchd (macOS) / systemd --user (Linux) /
|
|
2
|
+
// Task Scheduler (Windows). Runs `kai-bridge start` at login, restarts
|
|
3
|
+
// on crash, logs under ~/.kai/logs. `kai-bridge install` / `uninstall`.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir, platform } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const SERVICE_LABEL = "io.gleap.kai-bridge";
|
|
11
|
+
|
|
12
|
+
export function servicePaths(home = homedir()) {
|
|
13
|
+
return {
|
|
14
|
+
launchdPlist: join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`),
|
|
15
|
+
systemdUnit: join(home, ".config", "systemd", "user", "kai-bridge.service"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The command the service runs — the CLI entry of THIS install. */
|
|
20
|
+
/**
|
|
21
|
+
* True when the CLI is running out of npm's throwaway `npx` cache. A
|
|
22
|
+
* service installed from there points at a path npm garbage-collects,
|
|
23
|
+
* and the device goes Away for good with nothing to show why — so the
|
|
24
|
+
* installer refuses and asks for a global install instead.
|
|
25
|
+
*/
|
|
26
|
+
export function isEphemeralBinPath(binPath) {
|
|
27
|
+
return /[\\/]_npx[\\/]|[\\/]\.npm[\\/]_cacache[\\/]/.test(String(binPath || ""));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function serviceCommand(binPath, nodePath = process.execPath) {
|
|
31
|
+
return { program: nodePath, args: [binPath, "start"] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderLaunchdPlist({ program, args, logDir, env = {} }) {
|
|
35
|
+
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<");
|
|
36
|
+
const envXml = Object.entries(env)
|
|
37
|
+
.map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`)
|
|
38
|
+
.join("\n");
|
|
39
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0">
|
|
42
|
+
<dict>
|
|
43
|
+
<key>Label</key><string>${SERVICE_LABEL}</string>
|
|
44
|
+
<key>ProgramArguments</key>
|
|
45
|
+
<array>
|
|
46
|
+
${[program, ...args].map((a) => ` <string>${xml(a)}</string>`).join("\n")}
|
|
47
|
+
</array>
|
|
48
|
+
<key>RunAtLoad</key><true/>
|
|
49
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
50
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
51
|
+
<key>ProcessType</key><string>Adaptive</string>
|
|
52
|
+
<key>StandardOutPath</key><string>${xml(join(logDir, "service.out.log"))}</string>
|
|
53
|
+
<key>StandardErrorPath</key><string>${xml(join(logDir, "service.err.log"))}</string>
|
|
54
|
+
<key>EnvironmentVariables</key>
|
|
55
|
+
<dict>
|
|
56
|
+
${envXml}
|
|
57
|
+
</dict>
|
|
58
|
+
</dict>
|
|
59
|
+
</plist>
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderSystemdUnit({ program, args, env = {} }) {
|
|
64
|
+
const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
|
|
65
|
+
return `[Unit]
|
|
66
|
+
Description=Gleap Kai Bridge
|
|
67
|
+
Wants=network-online.target
|
|
68
|
+
After=network-online.target
|
|
69
|
+
|
|
70
|
+
[Service]
|
|
71
|
+
ExecStart=${[program, ...args].map(q).join(" ")}
|
|
72
|
+
Restart=on-failure
|
|
73
|
+
RestartSec=10
|
|
74
|
+
${Object.entries(env)
|
|
75
|
+
.map(([k, v]) => `Environment=${k}=${v}`)
|
|
76
|
+
.join("\n")}
|
|
77
|
+
|
|
78
|
+
[Install]
|
|
79
|
+
WantedBy=default.target
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function install({ binPath, logDir, env = {} }) {
|
|
84
|
+
const { program, args } = serviceCommand(binPath);
|
|
85
|
+
const paths = servicePaths();
|
|
86
|
+
mkdirSync(logDir, { recursive: true });
|
|
87
|
+
const os = platform();
|
|
88
|
+
// PATH: launchd/systemd start with a minimal PATH; the harness binaries
|
|
89
|
+
// (claude, codex) usually live in the user's shell PATH.
|
|
90
|
+
// SSH_AUTH_SOCK matters: pushing a branch uses the user's own git auth,
|
|
91
|
+
// and a LaunchAgent that never saw the agent socket fails every push
|
|
92
|
+
// with "Permission denied (publickey)" — while the same turn works
|
|
93
|
+
// perfectly when started from a terminal.
|
|
94
|
+
const fullEnv = {
|
|
95
|
+
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
|
96
|
+
HOME: homedir(),
|
|
97
|
+
...(process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {}),
|
|
98
|
+
...env,
|
|
99
|
+
};
|
|
100
|
+
if (os === "darwin") {
|
|
101
|
+
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
102
|
+
writeFileSync(paths.launchdPlist, renderLaunchdPlist({ program, args, logDir, env: fullEnv }));
|
|
103
|
+
try {
|
|
104
|
+
execFileSync("launchctl", ["unload", paths.launchdPlist], { stdio: "ignore" });
|
|
105
|
+
} catch {
|
|
106
|
+
/* not loaded */
|
|
107
|
+
}
|
|
108
|
+
execFileSync("launchctl", ["load", "-w", paths.launchdPlist], { stdio: "ignore" });
|
|
109
|
+
return { kind: "launchd", path: paths.launchdPlist };
|
|
110
|
+
}
|
|
111
|
+
if (os === "linux") {
|
|
112
|
+
mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
|
|
113
|
+
writeFileSync(paths.systemdUnit, renderSystemdUnit({ program, args, env: fullEnv }));
|
|
114
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
115
|
+
execFileSync("systemctl", ["--user", "enable", "--now", "kai-bridge.service"], { stdio: "ignore" });
|
|
116
|
+
return { kind: "systemd", path: paths.systemdUnit };
|
|
117
|
+
}
|
|
118
|
+
if (os === "win32") {
|
|
119
|
+
const cmd = `"${program}" ${args.map((a) => `"${a}"`).join(" ")}`;
|
|
120
|
+
execFileSync("schtasks", ["/Create", "/F", "/SC", "ONLOGON", "/TN", "GleapKaiBridge", "/TR", cmd], { stdio: "ignore" });
|
|
121
|
+
execFileSync("schtasks", ["/Run", "/TN", "GleapKaiBridge"], { stdio: "ignore" });
|
|
122
|
+
return { kind: "schtasks", path: "GleapKaiBridge" };
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`Unsupported platform ${os}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function uninstall() {
|
|
128
|
+
const paths = servicePaths();
|
|
129
|
+
const os = platform();
|
|
130
|
+
if (os === "darwin" && existsSync(paths.launchdPlist)) {
|
|
131
|
+
try {
|
|
132
|
+
execFileSync("launchctl", ["unload", "-w", paths.launchdPlist], { stdio: "ignore" });
|
|
133
|
+
} catch {
|
|
134
|
+
/* not loaded */
|
|
135
|
+
}
|
|
136
|
+
rmSync(paths.launchdPlist, { force: true });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (os === "linux" && existsSync(paths.systemdUnit)) {
|
|
140
|
+
try {
|
|
141
|
+
execFileSync("systemctl", ["--user", "disable", "--now", "kai-bridge.service"], { stdio: "ignore" });
|
|
142
|
+
} catch {
|
|
143
|
+
/* not enabled */
|
|
144
|
+
}
|
|
145
|
+
rmSync(paths.systemdUnit, { force: true });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (os === "win32") {
|
|
149
|
+
try {
|
|
150
|
+
execFileSync("schtasks", ["/Delete", "/F", "/TN", "GleapKaiBridge"], { stdio: "ignore" });
|
|
151
|
+
return true;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isInstalled() {
|
|
160
|
+
const paths = servicePaths();
|
|
161
|
+
return platform() === "darwin" ? existsSync(paths.launchdPlist) : platform() === "linux" ? existsSync(paths.systemdUnit) : false;
|
|
162
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Interactive onboarding — the whole first run in one guided flow:
|
|
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. say it's done, nicely
|
|
7
|
+
//
|
|
8
|
+
// Runs from `kai-bridge setup`, from a bare `kai-bridge` on an unpaired
|
|
9
|
+
// machine, and (when npm shows script output — `--foreground-scripts`)
|
|
10
|
+
// straight from `npm i -g @gleapai/kai-bridge`'s postinstall. Re-running is
|
|
11
|
+
// safe: every step detects its current state and offers the next thing,
|
|
12
|
+
// so it doubles as "reconnect", "add a harness", and "log out".
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
|
+
|
|
19
|
+
import { BridgeApi } from "./api.mjs";
|
|
20
|
+
import { KAI_HOME, deviceDefaults, loadConfig, saveConfig } from "./config.mjs";
|
|
21
|
+
import { describeHarnesses, installHarness, HARNESS_INFO } from "./harnesses.mjs";
|
|
22
|
+
import { ambientConfigDir, describeProfiles, loginCommand } from "./profiles.mjs";
|
|
23
|
+
import { install, isInstalled, isEphemeralBinPath, uninstall } from "./service.mjs";
|
|
24
|
+
|
|
25
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The background service is MACHINE-global (one launchd/systemd unit, and
|
|
29
|
+
* it always loads the default ~/.kai), while KAI_HOME can point a CLI run
|
|
30
|
+
* at any sandbox config. Service install/uninstall must therefore only
|
|
31
|
+
* happen when this run actually operates on the home the service uses —
|
|
32
|
+
* a `KAI_HOME=/tmp/test kai-bridge logout` must never take down the real
|
|
33
|
+
* machine's service.
|
|
34
|
+
*/
|
|
35
|
+
const isServiceHome = () => resolve(KAI_HOME) === resolve(join(homedir(), ".kai"));
|
|
36
|
+
|
|
37
|
+
// ── Tiny prompt helpers (node:readline — no dependency) ──────────────
|
|
38
|
+
//
|
|
39
|
+
// Lines are buffered and EOF resolves pending questions with "" (the
|
|
40
|
+
// default answer), so a piped stdin — `printf 'y\n' | kai-bridge setup`,
|
|
41
|
+
// CI, or a closed terminal — walks through cleanly instead of throwing
|
|
42
|
+
// "readline was closed" between two slow probe steps.
|
|
43
|
+
function makePrompter({ input = process.stdin, output = process.stdout } = {}) {
|
|
44
|
+
const rl = createInterface({ input, output });
|
|
45
|
+
const queue = [];
|
|
46
|
+
const waiting = [];
|
|
47
|
+
let closed = false;
|
|
48
|
+
rl.on("line", (line) => {
|
|
49
|
+
const next = waiting.shift();
|
|
50
|
+
if (next) next(line.trim());
|
|
51
|
+
else queue.push(line.trim());
|
|
52
|
+
});
|
|
53
|
+
rl.on("close", () => {
|
|
54
|
+
closed = true;
|
|
55
|
+
while (waiting.length > 0) waiting.shift()("");
|
|
56
|
+
});
|
|
57
|
+
const ask = (q) => {
|
|
58
|
+
if (queue.length > 0) {
|
|
59
|
+
output.write(q + "\n");
|
|
60
|
+
return Promise.resolve(queue.shift());
|
|
61
|
+
}
|
|
62
|
+
if (closed) return Promise.resolve("");
|
|
63
|
+
output.write(q);
|
|
64
|
+
return new Promise((res) => waiting.push(res));
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
ask,
|
|
68
|
+
/** Enter/`y` → true, `n` → false. */
|
|
69
|
+
async yesNo(q, def = true) {
|
|
70
|
+
const a = (await ask(`${q} ${def ? "[Y/n]" : "[y/N]"} `)).toLowerCase();
|
|
71
|
+
if (!a) return def;
|
|
72
|
+
return a.startsWith("y");
|
|
73
|
+
},
|
|
74
|
+
close: () => rl.close(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const out = (s = "") => process.stdout.write(s + "\n");
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Device-code pairing, shared by `kai-bridge login` and the wizard.
|
|
82
|
+
* Mutates + saves config on success; throws on denial/timeout.
|
|
83
|
+
*/
|
|
84
|
+
export async function pairDevice({ config, name, print = out }) {
|
|
85
|
+
const api = new BridgeApi({ apiBase: config.apiBase });
|
|
86
|
+
const dev = { ...deviceDefaults(), name: name || deviceDefaults().name, version: "0.1.0" };
|
|
87
|
+
const start = await api.pairStart(dev);
|
|
88
|
+
const url = start.url || `${config.appBase}/connect-device?code=${start.code}`;
|
|
89
|
+
print(`\nOpen this link to connect "${dev.name}" to your Gleap account:\n\n ${url}\n\nCode: ${start.code}\n`);
|
|
90
|
+
try {
|
|
91
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
92
|
+
spawn(opener, process.platform === "win32" ? ["/c", "start", url] : [url], { stdio: "ignore", detached: true }).unref();
|
|
93
|
+
} catch {
|
|
94
|
+
/* user opens it by hand */
|
|
95
|
+
}
|
|
96
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
97
|
+
while (Date.now() < deadline) {
|
|
98
|
+
await sleep(3000);
|
|
99
|
+
const poll = await api.pairPoll(start.pollToken);
|
|
100
|
+
if (poll.status === "approved") {
|
|
101
|
+
config.device = { id: poll.device.id, name: poll.device.name, token: poll.token, organisationId: poll.device.organisationId, userId: poll.device.userId };
|
|
102
|
+
saveConfig(config);
|
|
103
|
+
print(`\nConnected as ${poll.device.name} (${poll.device.organisationName ?? poll.device.organisationId}).`);
|
|
104
|
+
return poll.device;
|
|
105
|
+
}
|
|
106
|
+
if (poll.status === "denied" || poll.status === "expired") throw new Error(`pairing ${poll.status}`);
|
|
107
|
+
process.stdout.write(".");
|
|
108
|
+
}
|
|
109
|
+
throw new Error("pairing timed out");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Disconnect this machine: tell the server (best-effort — works offline
|
|
114
|
+
* too), stop + remove the background service, forget the pairing locally.
|
|
115
|
+
* Harness logins are the user's own and stay untouched.
|
|
116
|
+
*/
|
|
117
|
+
export async function logout({ config = loadConfig(), print = out } = {}) {
|
|
118
|
+
if (!config.device?.token) {
|
|
119
|
+
print("This machine isn't connected — nothing to log out.");
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const name = config.device.name;
|
|
123
|
+
try {
|
|
124
|
+
await new BridgeApi({ apiBase: config.apiBase, token: config.device.token }).logout();
|
|
125
|
+
} catch {
|
|
126
|
+
print("(Couldn't reach Gleap to revoke the pairing — it was removed locally; you can also remove it under Kai Code → Settings → Devices.)");
|
|
127
|
+
}
|
|
128
|
+
if (isServiceHome() && isInstalled()) {
|
|
129
|
+
uninstall();
|
|
130
|
+
print("Background service removed.");
|
|
131
|
+
} else if (isInstalled()) {
|
|
132
|
+
print("(Background service left untouched — this KAI_HOME isn't the one the service runs on.)");
|
|
133
|
+
}
|
|
134
|
+
delete config.device;
|
|
135
|
+
saveConfig(config);
|
|
136
|
+
print(`${name} is disconnected from Gleap. Your Claude/Codex logins on this machine are untouched.`);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One line per harness with its live state, for the sign-in step. */
|
|
141
|
+
async function harnessRows(config) {
|
|
142
|
+
const { resolveProfiles } = await import("./daemon.mjs");
|
|
143
|
+
const resolved = resolveProfiles(config);
|
|
144
|
+
const described = await describeProfiles(resolved);
|
|
145
|
+
return describeHarnesses(KAI_HOME).map((h) => {
|
|
146
|
+
const pick = (list) => list.find((p) => p.harness === h.id && p.kind === "ambient") ?? list.find((p) => p.harness === h.id);
|
|
147
|
+
return { ...h, profile: pick(described), resolvedProfile: pick(resolved) };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const rowLine = (h) => {
|
|
152
|
+
const status = !h.installed
|
|
153
|
+
? "not installed"
|
|
154
|
+
: h.profile?.authState === "signed_in"
|
|
155
|
+
? `✓ signed in${h.profile.account ? ` as ${h.profile.account}` : ""}`
|
|
156
|
+
: "installed · signed out";
|
|
157
|
+
return `${h.label.padEnd(12)} ${status}`;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The wizard. `binPath` is the CLI entry (for the service plist);
|
|
162
|
+
* re-runnable at any time.
|
|
163
|
+
*/
|
|
164
|
+
export async function runSetup({ binPath, prompter = makePrompter() } = {}) {
|
|
165
|
+
const config = loadConfig();
|
|
166
|
+
out("");
|
|
167
|
+
out("── Kai Bridge ────────────────────────────────────────────────");
|
|
168
|
+
out("Run Kai Code sessions on this machine — with your own Claude /");
|
|
169
|
+
out("Codex / Cursor login, against your local checkouts.");
|
|
170
|
+
out("");
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
// 1 ── Pairing ---------------------------------------------------
|
|
174
|
+
if (config.device?.token) {
|
|
175
|
+
out(`This machine is already connected as ${config.device.name}.`);
|
|
176
|
+
const choice = (
|
|
177
|
+
await prompter.ask("Press Enter to keep it, `r` to reconnect, or `o` to log out and quit: ")
|
|
178
|
+
).toLowerCase();
|
|
179
|
+
if (choice === "o") {
|
|
180
|
+
await logout({ config });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (choice === "r") {
|
|
184
|
+
await logout({ config });
|
|
185
|
+
await pairDevice({ config });
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
out("Step 1 — connect this machine to your Gleap account.");
|
|
189
|
+
await pairDevice({ config });
|
|
190
|
+
}
|
|
191
|
+
out("");
|
|
192
|
+
|
|
193
|
+
// 2 ── Background service ---------------------------------------
|
|
194
|
+
if (!isServiceHome()) {
|
|
195
|
+
out("(Custom KAI_HOME — skipping the background service; it always runs on the machine's default ~/.kai.)");
|
|
196
|
+
} else if (binPath && isEphemeralBinPath(binPath)) {
|
|
197
|
+
out("(Running via npx — install it with `npm i -g @gleapai/kai-bridge` to add the background service.)");
|
|
198
|
+
} else if (isInstalled()) {
|
|
199
|
+
out("Background service: already installed — Kai stays reachable here.");
|
|
200
|
+
} else if (
|
|
201
|
+
await prompter.yesNo("Step 2 — keep Kai reachable in the background? Starts at login, restarts after crashes.", true)
|
|
202
|
+
) {
|
|
203
|
+
const res = install({ binPath, logDir: join(KAI_HOME, "logs") });
|
|
204
|
+
out(`Installed (${res.kind}) — running now, and again after every restart.`);
|
|
205
|
+
} else {
|
|
206
|
+
out("Skipped — run `kai-bridge start` whenever you want this machine available.");
|
|
207
|
+
}
|
|
208
|
+
out("");
|
|
209
|
+
|
|
210
|
+
// 3 ── Harness sign-ins ------------------------------------------
|
|
211
|
+
out("Step 3 — coding agents on this machine:");
|
|
212
|
+
let rows = await harnessRows(config);
|
|
213
|
+
for (const [i, h] of rows.entries()) out(` ${i + 1}. ${rowLine(h)}`);
|
|
214
|
+
const pick = await prompter.ask("Sign in / install now? Numbers like `1,3`, or Enter to skip: ");
|
|
215
|
+
const picked = pick
|
|
216
|
+
.split(/[\s,]+/)
|
|
217
|
+
.map((n) => rows[Number(n) - 1])
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
for (const h of picked) {
|
|
220
|
+
if (!h.installed) {
|
|
221
|
+
out(`Installing ${h.label}…`);
|
|
222
|
+
const res = await installHarness(h.id, { kaiHome: KAI_HOME, onLog: out });
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
out(`Couldn't install ${h.label} — skipping (guide: ${HARNESS_INFO[h.id]?.label} docs).`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (h.profile?.authState === "signed_in") {
|
|
229
|
+
out(`${h.label} is already signed in — nothing to do.`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const configDir = h.resolvedProfile?.configDir ?? ambientConfigDir(h.id);
|
|
233
|
+
const c = loginCommand(h.id, configDir);
|
|
234
|
+
if (!c) {
|
|
235
|
+
out(`${h.label} has no login command — skipping.`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
out(`\nSigning in to ${h.label} — follow its own prompts:`);
|
|
239
|
+
await new Promise((res) => spawn(c.cmd, c.args, { env: c.env, stdio: "inherit" }).on("close", res));
|
|
240
|
+
}
|
|
241
|
+
if (picked.length > 0) {
|
|
242
|
+
rows = await harnessRows(config);
|
|
243
|
+
out("");
|
|
244
|
+
for (const h of rows) out(` ${rowLine(h)}`);
|
|
245
|
+
}
|
|
246
|
+
out("");
|
|
247
|
+
|
|
248
|
+
// 4 ── Done -------------------------------------------------------
|
|
249
|
+
const name = config.device?.name ?? "This machine";
|
|
250
|
+
out("──────────────────────────────────────────────────────────────");
|
|
251
|
+
out(`🎉 All set. ${name} now shows up in Kai Code.`);
|
|
252
|
+
out("");
|
|
253
|
+
out("Open Gleap → Kai Code, pick this machine in the Run-on menu,");
|
|
254
|
+
out("and sessions run right here — on your own subscription, with");
|
|
255
|
+
out("your local checkouts and dev servers. Happy shipping!");
|
|
256
|
+
out("");
|
|
257
|
+
out("Handy later: kai-bridge status · kai-bridge setup · kai-bridge logout");
|
|
258
|
+
} finally {
|
|
259
|
+
prompter.close();
|
|
260
|
+
}
|
|
261
|
+
}
|