@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/executor.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Runs ONE Kai Code turn on this device with the bundled ACP runner — the
|
|
2
|
+
// same driver the cloud sandbox uses (runner/acp-runner.mjs), pointed at
|
|
3
|
+
// the user's harness login instead of Gleap's keys.
|
|
4
|
+
//
|
|
5
|
+
// Input = the Server's `turn.start` payload (mirrors AgentRunOpts on the
|
|
6
|
+
// analyzer host); output = the contract JSONL events, delivered through
|
|
7
|
+
// `onEvent` as they stream, plus the final `result`.
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
import { ambientConfigDir, managedConfigDir } from "./profiles.mjs";
|
|
16
|
+
import { harnessAcpCommand } from "./harnesses.mjs";
|
|
17
|
+
import { KAI_HOME } from "./config.mjs";
|
|
18
|
+
|
|
19
|
+
const RUNNER = join(dirname(fileURLToPath(import.meta.url)), "..", "runner", "acp-runner.mjs");
|
|
20
|
+
const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v), "utf8").toString("base64");
|
|
21
|
+
|
|
22
|
+
/** turn.start payload → runner argv (no shell — we spawn node directly). */
|
|
23
|
+
export function buildRunnerArgs(turn, workDir) {
|
|
24
|
+
const args = ["--task-b64", b64(turn.task || ""), "--work-dir", workDir];
|
|
25
|
+
// The Server's coder payload signals plan mode as `planMode`, not as an
|
|
26
|
+
// agent name (the analyzer host does this same mapping before spawning
|
|
27
|
+
// its runner). Without it, a device Plan turn silently ran as build:
|
|
28
|
+
// full write permissions, no plan artifact, no "Implement this plan?".
|
|
29
|
+
const agent = turn.agent ?? (turn.planMode ? "kai-planner" : undefined);
|
|
30
|
+
if (agent) args.push("--agent", agent);
|
|
31
|
+
if (turn.model) args.push("--model", turn.model);
|
|
32
|
+
if (turn.engineModelSlug) args.push("--engine-model", turn.engineModelSlug);
|
|
33
|
+
if (turn.subagentModel) args.push("--subagent-model", turn.subagentModel);
|
|
34
|
+
if (turn.effort) args.push("--effort", turn.effort);
|
|
35
|
+
// ACP resume id when the server split it out (acpSessionId); the
|
|
36
|
+
// canonical session id otherwise (turn 1 — the runner treats it as a
|
|
37
|
+
// hint and mints its own ACP id).
|
|
38
|
+
if (turn.acpSessionId || turn.sessionId) args.push("--session-id", turn.acpSessionId || turn.sessionId);
|
|
39
|
+
if (turn.harness) args.push("--harness", turn.harness);
|
|
40
|
+
if (turn.maxSteps > 0) args.push("--max-steps", String(Math.floor(turn.maxSteps)));
|
|
41
|
+
if (turn.maxBudgetUsd > 0) args.push("--max-budget-usd", String(turn.maxBudgetUsd));
|
|
42
|
+
if (turn.feedback) args.push("--feedback-b64", b64(turn.feedback));
|
|
43
|
+
if (turn.questionAnswers?.length) args.push("--answers-b64", b64(turn.questionAnswers));
|
|
44
|
+
if (turn.attachments?.length) args.push("--attachments-b64", b64(turn.attachments));
|
|
45
|
+
if (turn.mcpServers?.length) args.push("--mcp-config-b64", b64(turn.mcpServers));
|
|
46
|
+
if (turn.customInstructions?.trim()) args.push("--system-prompt-b64", b64(turn.customInstructions));
|
|
47
|
+
if (turn.modelPricing && Object.keys(turn.modelPricing).length) args.push("--model-pricing-b64", b64(turn.modelPricing));
|
|
48
|
+
if (turn.providerOrders && Object.keys(turn.providerOrders).length) args.push("--provider-orders-b64", b64(turn.providerOrders));
|
|
49
|
+
if (turn.modelLimits?.contextWindow) args.push("--max-context-tokens", String(turn.modelLimits.contextWindow));
|
|
50
|
+
if (turn.modelLimits?.maxOutputTokens) args.push("--max-output-tokens", String(turn.modelLimits.maxOutputTokens));
|
|
51
|
+
if (turn.backgroundModel) args.push("--background-model", turn.backgroundModel);
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Env for the runner given the profile:
|
|
57
|
+
* ambient/managed → the harness's own login dir; the runner's
|
|
58
|
+
* `KAI_ACP_STATE_DIR` is redirected so CLAUDE_CONFIG_DIR/CODEX_HOME
|
|
59
|
+
* point at the PROFILE (that's where the login lives), and the
|
|
60
|
+
* transcript is read from there too.
|
|
61
|
+
* gleap-key → keys handed over in `turn.credentials`, isolated state dir.
|
|
62
|
+
*/
|
|
63
|
+
export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME) {
|
|
64
|
+
const env = { ...process.env, KAI_RUNNER_DEBUG: process.env.KAI_RUNNER_DEBUG || "0", KAI_PERSONA_DIR: join(dirname(RUNNER), "personas") };
|
|
65
|
+
// Never leak the Gleap device token into the harness.
|
|
66
|
+
delete env.KAI_DEVICE_TOKEN;
|
|
67
|
+
if (profile.kind === "gleap-key") {
|
|
68
|
+
env.KAI_ACP_STATE_DIR = join(kaiHome, "state", "gleap-key");
|
|
69
|
+
for (const [k, v] of Object.entries(turn.credentials || {})) env[k] = String(v);
|
|
70
|
+
} else {
|
|
71
|
+
const profileDir = profile.kind === "managed" ? managedConfigDir(profile.harness, profile.id, kaiHome) : ambientConfigDir(profile.harness);
|
|
72
|
+
env.KAI_ACP_STATE_DIR = join(kaiHome, "state", profile.id);
|
|
73
|
+
if (profile.harness === "cursor") {
|
|
74
|
+
// Cursor keeps its login in ~/.cursor with no per-profile dir; the
|
|
75
|
+
// runner only needs a scratch state dir.
|
|
76
|
+
env.KAI_ACP_CONFIG_DIR = join(kaiHome, "state", profile.id, "cursor");
|
|
77
|
+
mkdirSync(env.KAI_ACP_CONFIG_DIR, { recursive: true });
|
|
78
|
+
} else if (profile.harness === "codex") {
|
|
79
|
+
// The runner writes config.toml (MCP servers, kill-switches) into
|
|
80
|
+
// CODEX_HOME — that must never be the user's real ~/.codex. Use a
|
|
81
|
+
// per-profile session dir seeded with a COPY of auth.json (read-only
|
|
82
|
+
// reuse of the ChatGPT login; never written back).
|
|
83
|
+
const sessionDir = join(kaiHome, "state", profile.id, "codex");
|
|
84
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
85
|
+
const src = join(profileDir, "auth.json");
|
|
86
|
+
if (existsSync(src)) copyFileSync(src, join(sessionDir, "auth.json"));
|
|
87
|
+
env.KAI_ACP_CONFIG_DIR = sessionDir;
|
|
88
|
+
} else {
|
|
89
|
+
// Claude: CLAUDE_CONFIG_DIR IS the login dir; the runner reads the
|
|
90
|
+
// transcript from it and passes settings via the SDK, never by file.
|
|
91
|
+
// Ambient = the CLI's own default dir, where macOS keeps OAuth in
|
|
92
|
+
// the keychain ONLY while CLAUDE_CONFIG_DIR stays unset — so the
|
|
93
|
+
// runner is told to inherit the env rather than export the dir.
|
|
94
|
+
env.KAI_ACP_CONFIG_DIR = profileDir;
|
|
95
|
+
if (profile.kind !== "managed") env.KAI_ACP_CONFIG_DIR_AMBIENT = "1";
|
|
96
|
+
}
|
|
97
|
+
// BYO login: the runner must not require Gleap keys.
|
|
98
|
+
env.KAI_ACP_BYO_LOGIN = "1";
|
|
99
|
+
delete env.ANTHROPIC_API_KEY;
|
|
100
|
+
delete env.OPENAI_API_KEY;
|
|
101
|
+
delete env.OPENROUTER_API_KEY;
|
|
102
|
+
}
|
|
103
|
+
// The ACP adapter for this harness: bundled bins for claude/codex, the
|
|
104
|
+
// bridge-installed Cursor Agent by path (never the vendor symlink).
|
|
105
|
+
// Precedence: explicit test/dev override (env) → turn → harness registry.
|
|
106
|
+
const acp = process.env.KAI_ACP_AGENT_CMD ? null : turn.agentCommand || harnessAcpCommand(profile.harness, kaiHome);
|
|
107
|
+
if (acp) env.KAI_ACP_AGENT_CMD = JSON.stringify(acp);
|
|
108
|
+
return env;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Execute a turn. Resolves `{ code, result, rateLimited }`; events stream
|
|
113
|
+
* through `onEvent(event)`. `signal` cancels (SIGTERM to the runner).
|
|
114
|
+
*/
|
|
115
|
+
export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, signal, kaiHome = KAI_HOME }) {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
const args = buildRunnerArgs(turn, workDir);
|
|
118
|
+
const env = buildRunnerEnv(turn, profile, kaiHome);
|
|
119
|
+
const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
120
|
+
let result = null;
|
|
121
|
+
let rateLimited = false;
|
|
122
|
+
let lastError = null;
|
|
123
|
+
const rl = createInterface({ input: child.stdout });
|
|
124
|
+
rl.on("line", (line) => {
|
|
125
|
+
const t = line.trim();
|
|
126
|
+
if (!t) return;
|
|
127
|
+
let ev;
|
|
128
|
+
try {
|
|
129
|
+
ev = JSON.parse(t);
|
|
130
|
+
} catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (ev?.type === "result") result = ev;
|
|
134
|
+
if (ev?.type === "error") {
|
|
135
|
+
// Keep the most recent human-readable failure so the daemon can
|
|
136
|
+
// report WHY instead of a generic "turn failed".
|
|
137
|
+
lastError = String(ev.message || "");
|
|
138
|
+
if (/rate.?limit|429|usage limit|out of (extra )?usage/i.test(lastError)) rateLimited = true;
|
|
139
|
+
}
|
|
140
|
+
onEvent(ev);
|
|
141
|
+
});
|
|
142
|
+
child.stderr.on("data", (d) => onLog(String(d)));
|
|
143
|
+
const onAbort = () => {
|
|
144
|
+
try {
|
|
145
|
+
child.kill("SIGTERM");
|
|
146
|
+
} catch {
|
|
147
|
+
/* gone */
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
151
|
+
child.on("close", (code) => {
|
|
152
|
+
signal?.removeEventListener("abort", onAbort);
|
|
153
|
+
resolve({ code, result, rateLimited, lastError });
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Harness registry: which coding agents this device can run, how to find
|
|
2
|
+
// their binaries, install/update them, probe their login, and open a
|
|
3
|
+
// login.
|
|
4
|
+
//
|
|
5
|
+
// claude — Claude Code. BUNDLED: the ACP adapter ships the Agent SDK,
|
|
6
|
+
// which ships the CLI. Nothing to install; only a login.
|
|
7
|
+
// codex — Codex CLI. BUNDLED by codex-acp. Only a login.
|
|
8
|
+
// cursor — Cursor Agent CLI. NOT bundled (no npm package); installed by
|
|
9
|
+
// the bridge from Cursor's release tarball into
|
|
10
|
+
// ~/.kai/harnesses/cursor/<version>/ and called by path — never
|
|
11
|
+
// via the vendor's ~/.local/bin/agent symlink (other vendors
|
|
12
|
+
// use that name too: Grok's CLI on at least one dev machine).
|
|
13
|
+
//
|
|
14
|
+
// "Installed" therefore means: claude/codex always; cursor when the
|
|
15
|
+
// tarball is present. "Signed in" is per profile (see profiles.mjs).
|
|
16
|
+
|
|
17
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
18
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { arch, homedir, platform } from "node:os";
|
|
20
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const PKG_BIN = join(PKG, "node_modules", ".bin");
|
|
25
|
+
|
|
26
|
+
export const HARNESS_IDS = ["claude", "codex", "cursor"];
|
|
27
|
+
|
|
28
|
+
export const HARNESS_INFO = {
|
|
29
|
+
claude: {
|
|
30
|
+
id: "claude",
|
|
31
|
+
label: "Claude Code",
|
|
32
|
+
vendor: "Anthropic",
|
|
33
|
+
bundled: true,
|
|
34
|
+
accountHint: "Claude Pro / Max, Team, or an Anthropic API key",
|
|
35
|
+
loginHint: "Sign in with your Claude account — a browser window opens.",
|
|
36
|
+
docsUrl: "https://code.claude.com/docs",
|
|
37
|
+
},
|
|
38
|
+
codex: {
|
|
39
|
+
id: "codex",
|
|
40
|
+
label: "Codex",
|
|
41
|
+
vendor: "OpenAI",
|
|
42
|
+
bundled: true,
|
|
43
|
+
accountHint: "ChatGPT Plus / Pro / Team, or an OpenAI API key",
|
|
44
|
+
loginHint: "Sign in with your ChatGPT account — a browser window opens.",
|
|
45
|
+
docsUrl: "https://developers.openai.com/codex/cli",
|
|
46
|
+
},
|
|
47
|
+
cursor: {
|
|
48
|
+
id: "cursor",
|
|
49
|
+
label: "Cursor",
|
|
50
|
+
vendor: "Cursor",
|
|
51
|
+
bundled: false,
|
|
52
|
+
accountHint: "Cursor subscription (or CURSOR_API_KEY)",
|
|
53
|
+
loginHint: "Sign in with your Cursor account — a browser window opens.",
|
|
54
|
+
docsUrl: "https://cursor.com/docs/cli",
|
|
55
|
+
/** Local-only: Gleap has no Cursor credentials, so the cloud never offers it. */
|
|
56
|
+
localOnly: true,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const CURSOR_ROOT = (kaiHome) => join(kaiHome, "harnesses", "cursor");
|
|
61
|
+
const CURSOR_CURRENT = (kaiHome) => join(CURSOR_ROOT(kaiHome), "current");
|
|
62
|
+
/** Version + channel published by Cursor's install script (https://cursor.com/install). */
|
|
63
|
+
export const CURSOR_AGENT_VERSION = process.env.KAI_CURSOR_AGENT_VERSION || "2026.08.11-e8db854";
|
|
64
|
+
|
|
65
|
+
function cursorPlatform() {
|
|
66
|
+
const os = platform() === "darwin" ? "darwin" : platform() === "win32" ? "windows" : "linux";
|
|
67
|
+
const a = arch() === "arm64" ? "arm64" : "x64";
|
|
68
|
+
return { os, arch: a };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function cursorDownloadUrl(version = CURSOR_AGENT_VERSION) {
|
|
72
|
+
const { os, arch: a } = cursorPlatform();
|
|
73
|
+
return `https://downloads.cursor.com/lab/${version}/${os}/${a}/agent-cli-package.tar.gz`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Bundled / installed binary for a harness, or null. */
|
|
77
|
+
export function harnessBinary(harness, kaiHome = join(homedir(), ".kai")) {
|
|
78
|
+
if (harness === "claude") {
|
|
79
|
+
const pkgDir = join(PKG, "node_modules", "@anthropic-ai");
|
|
80
|
+
try {
|
|
81
|
+
const plat = readdirSync(pkgDir).find((d) => d.startsWith("claude-agent-sdk-") && d !== "claude-agent-sdk");
|
|
82
|
+
if (plat) {
|
|
83
|
+
const bin = join(pkgDir, plat, platform() === "win32" ? "claude.exe" : "claude");
|
|
84
|
+
if (existsSync(bin)) return bin;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* not installed */
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (harness === "codex") {
|
|
92
|
+
const bin = join(PKG_BIN, platform() === "win32" ? "codex.cmd" : "codex");
|
|
93
|
+
return existsSync(bin) ? bin : null;
|
|
94
|
+
}
|
|
95
|
+
if (harness === "cursor") {
|
|
96
|
+
const bin = join(CURSOR_CURRENT(kaiHome), "dist-package", platform() === "win32" ? "cursor-agent.exe" : "cursor-agent");
|
|
97
|
+
return existsSync(bin) ? bin : null;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The ACP adapter command the runner should spawn for a harness. */
|
|
103
|
+
export function harnessAcpCommand(harness, kaiHome) {
|
|
104
|
+
if (harness === "claude") return { cmd: join(PKG_BIN, "claude-agent-acp"), args: [] };
|
|
105
|
+
if (harness === "codex") return { cmd: join(PKG_BIN, "codex-acp"), args: [] };
|
|
106
|
+
if (harness === "cursor") {
|
|
107
|
+
const bin = harnessBinary("cursor", kaiHome);
|
|
108
|
+
return bin ? { cmd: bin, args: ["acp"] } : null;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function versionOf(bin, args = ["--version"]) {
|
|
114
|
+
try {
|
|
115
|
+
return execFileSync(bin, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }).trim().split(/\r?\n/)[0] || null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Install / version state per harness (login state is per profile). */
|
|
122
|
+
export function describeHarnesses(kaiHome) {
|
|
123
|
+
return HARNESS_IDS.map((id) => {
|
|
124
|
+
const bin = harnessBinary(id, kaiHome);
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
label: HARNESS_INFO[id].label,
|
|
128
|
+
bundled: HARNESS_INFO[id].bundled,
|
|
129
|
+
localOnly: !!HARNESS_INFO[id].localOnly,
|
|
130
|
+
installed: !!bin,
|
|
131
|
+
version: bin ? versionOf(bin) : null,
|
|
132
|
+
binary: bin,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Install (or update) a harness. Bundled ones are no-ops. Cursor:
|
|
139
|
+
* download the release tarball for this platform into
|
|
140
|
+
* ~/.kai/harnesses/cursor/<version>/ and point `current` at it.
|
|
141
|
+
* `onLog(line)` receives progress; resolves `{ ok, version, binary }`.
|
|
142
|
+
*/
|
|
143
|
+
export async function installHarness(harness, { kaiHome, onLog = () => {}, version = CURSOR_AGENT_VERSION } = {}) {
|
|
144
|
+
if (!HARNESS_IDS.includes(harness)) throw new Error(`unknown harness ${harness}`);
|
|
145
|
+
if (HARNESS_INFO[harness].bundled) {
|
|
146
|
+
const bin = harnessBinary(harness, kaiHome);
|
|
147
|
+
onLog(`${HARNESS_INFO[harness].label} is bundled with the bridge (${bin ? versionOf(bin) : "missing — run npm install"})`);
|
|
148
|
+
return { ok: !!bin, version: bin ? versionOf(bin) : null, binary: bin };
|
|
149
|
+
}
|
|
150
|
+
const root = CURSOR_ROOT(kaiHome);
|
|
151
|
+
const target = join(root, version);
|
|
152
|
+
const tmp = join(root, `.tmp-${version}-${process.pid}`);
|
|
153
|
+
mkdirSync(root, { recursive: true });
|
|
154
|
+
if (!existsSync(join(target, "dist-package", "cursor-agent"))) {
|
|
155
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
156
|
+
mkdirSync(tmp, { recursive: true });
|
|
157
|
+
const url = cursorDownloadUrl(version);
|
|
158
|
+
onLog(`Downloading Cursor Agent ${version} (${url})`);
|
|
159
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(300_000) });
|
|
160
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
|
|
161
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
162
|
+
const tarPath = join(tmp, "agent-cli-package.tar.gz");
|
|
163
|
+
writeFileSync(tarPath, buf);
|
|
164
|
+
onLog(`Extracting ${(buf.length / 1e6).toFixed(1)} MB`);
|
|
165
|
+
execFileSync("tar", ["-xzf", tarPath, "-C", tmp], { stdio: "ignore" });
|
|
166
|
+
unlinkSync(tarPath);
|
|
167
|
+
rmSync(target, { recursive: true, force: true });
|
|
168
|
+
renameSync(tmp, target);
|
|
169
|
+
try {
|
|
170
|
+
chmodSync(join(target, "dist-package", "cursor-agent"), 0o755);
|
|
171
|
+
} catch {
|
|
172
|
+
/* windows */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const current = CURSOR_CURRENT(kaiHome);
|
|
176
|
+
try {
|
|
177
|
+
unlinkSync(current);
|
|
178
|
+
} catch {
|
|
179
|
+
/* none yet */
|
|
180
|
+
}
|
|
181
|
+
symlinkSync(target, current, "dir");
|
|
182
|
+
const bin = harnessBinary("cursor", kaiHome);
|
|
183
|
+
const v = bin ? versionOf(bin) : null;
|
|
184
|
+
onLog(`Cursor Agent ready: ${v}`);
|
|
185
|
+
return { ok: !!bin, version: v, binary: bin };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Interactive login command for a harness under a profile's config dir. */
|
|
189
|
+
export function harnessLoginCommand(harness, configDir, kaiHome) {
|
|
190
|
+
const bin = harnessBinary(harness, kaiHome);
|
|
191
|
+
if (!bin) return null;
|
|
192
|
+
if (harness === "claude") {
|
|
193
|
+
const claudeDefault = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
194
|
+
// Ambient login must land in the keychain (env untouched) so the
|
|
195
|
+
// equally-untouched probe and turns find it; managed dirs are exported.
|
|
196
|
+
const env = resolvePath(configDir) !== resolvePath(claudeDefault) ? { CLAUDE_CONFIG_DIR: configDir } : {};
|
|
197
|
+
return { cmd: bin, args: ["login"], env };
|
|
198
|
+
}
|
|
199
|
+
if (harness === "codex") return { cmd: bin, args: ["login"], env: { CODEX_HOME: configDir } };
|
|
200
|
+
return { cmd: bin, args: ["login"], env: {} };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Auth probe per harness; returns { state, account? }. */
|
|
204
|
+
export function probeHarnessAuth(harness, configDir, kaiHome) {
|
|
205
|
+
const bin = harnessBinary(harness, kaiHome);
|
|
206
|
+
if (!bin) return { state: "missing_binary" };
|
|
207
|
+
// Claude keeps OAuth in the macOS keychain ONLY while CLAUDE_CONFIG_DIR
|
|
208
|
+
// is unset — exporting it (even set to the default ~/.claude) flips the
|
|
209
|
+
// CLI to file-based credentials and a real login probes as signed-out.
|
|
210
|
+
// So the ambient dir inherits the environment untouched; only managed
|
|
211
|
+
// profile dirs are exported. Codex/Cursor are file-based — always safe.
|
|
212
|
+
const claudeDefault = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
213
|
+
const claudeOverride = harness === "claude" && resolvePath(configDir) !== resolvePath(claudeDefault);
|
|
214
|
+
const env = { ...process.env, ...(claudeOverride ? { CLAUDE_CONFIG_DIR: configDir } : harness === "codex" ? { CODEX_HOME: configDir } : {}) };
|
|
215
|
+
try {
|
|
216
|
+
if (harness === "claude") {
|
|
217
|
+
const out = execFileSync(bin, ["auth", "status"], { env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 });
|
|
218
|
+
let account;
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(out);
|
|
221
|
+
if (parsed?.loggedIn === false) return { state: "signed_out" };
|
|
222
|
+
account = parsed?.email ?? parsed?.account?.email;
|
|
223
|
+
} catch {
|
|
224
|
+
account = /([\w.+-]+@[\w.-]+\.\w+)/.exec(out)?.[1];
|
|
225
|
+
}
|
|
226
|
+
return { state: "signed_in", account };
|
|
227
|
+
}
|
|
228
|
+
if (harness === "codex") {
|
|
229
|
+
const auth = JSON.parse(readFileSync(join(configDir, "auth.json"), "utf8"));
|
|
230
|
+
const signedIn = !!(auth?.tokens?.access_token || auth?.OPENAI_API_KEY);
|
|
231
|
+
return { state: signedIn ? "signed_in" : "signed_out", account: auth?.tokens?.account_id ? "ChatGPT" : auth?.OPENAI_API_KEY ? "API key" : undefined };
|
|
232
|
+
}
|
|
233
|
+
if (harness === "cursor") {
|
|
234
|
+
if (process.env.CURSOR_API_KEY) return { state: "signed_in", account: "API key" };
|
|
235
|
+
const out = execFileSync(bin, ["status"], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20000 });
|
|
236
|
+
if (/not logged in/i.test(out)) return { state: "signed_out" };
|
|
237
|
+
const account = /([\w.+-]+@[\w.-]+\.\w+)/.exec(out)?.[1];
|
|
238
|
+
return { state: "signed_in", account };
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
// `claude auth status` exits 1 when signed out; codex without auth.json.
|
|
242
|
+
return { state: "signed_out" };
|
|
243
|
+
}
|
|
244
|
+
return { state: "unknown" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Spawn a detached process that keeps running after the CLI exits (installer helper). */
|
|
248
|
+
export function spawnDetached(cmd, args, opts = {}) {
|
|
249
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true, ...opts });
|
|
250
|
+
child.unref();
|
|
251
|
+
return child;
|
|
252
|
+
}
|