@miraland-labs/conduit-bridge 0.1.0 → 0.4.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
@@ -5,29 +5,54 @@ Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connect
5
5
  ## Prerequisites
6
6
 
7
7
  - Node.js 20+
8
- - **Claude Code** (`claude` on PATH) for automated execution today
8
+ - One supported agent CLI on PATH:
9
+ - **Claude Code** — `claude` (Conduit pump or Anthropic login)
10
+ - **Codex** — `codex` (Conduit pump or ChatGPT/OpenAI login). Since the July 2026 ChatGPT desktop merge, Bridge also finds the CLI bundled inside ChatGPT.app when `codex` is not on PATH (macOS)
11
+ - **OpenCode** — `opencode` (Conduit pump or local provider login)
12
+ - **Cursor Agent** — `agent` (requires `conduit fuel local` + `CURSOR_API_KEY` / Cursor login)
13
+ - **Kiro CLI** — `kiro-cli` (requires `conduit fuel local` + `KIRO_API_KEY` / `kiro-cli login`)
14
+ - **Antigravity** — `agy` (requires `conduit fuel local` + `GEMINI_API_KEY` / `agy login`)
9
15
  - macOS or Linux for `install-service` (Windows: keep a terminal runner open)
10
16
 
11
17
  ## Install / run
12
18
 
13
- No global install required:
14
-
15
19
  ```bash
16
20
  npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
17
21
  ```
18
22
 
19
- After approval, keep the computer executing work (not heartbeat-only):
23
+ After approval:
20
24
 
21
25
  ```bash
22
26
  npx @miraland-labs/conduit-bridge runner --agent claude-code --workspace /path/to/repo
23
- npx @miraland-labs/conduit-bridge install-service --agent claude-code --workspace /path/to/repo
27
+ npx @miraland-labs/conduit-bridge runner --agent codex --workspace /path/to/repo
28
+ npx @miraland-labs/conduit-bridge runner --agent opencode --workspace /path/to/repo
29
+ npx @miraland-labs/conduit-bridge fuel local # required before cursor / kiro / antigravity
30
+ npx @miraland-labs/conduit-bridge runner --agent cursor --workspace /path/to/repo
31
+ npx @miraland-labs/conduit-bridge runner --agent kiro --workspace /path/to/repo
32
+ npx @miraland-labs/conduit-bridge runner --agent antigravity --workspace /path/to/repo
24
33
  ```
25
34
 
26
- Optional global install creates a `conduit` shim on PATH:
35
+ Persist (macOS/Linux):
27
36
 
28
37
  ```bash
29
- npm install -g @miraland-labs/conduit-bridge
30
- conduit join --url <https://your-conduit> --organization <slug>
38
+ npx @miraland-labs/conduit-bridge install-service --agent <claude-code|codex|cursor|opencode|kiro|antigravity> --workspace /path/to/repo
31
39
  ```
32
40
 
33
- Detected IDEs (Cursor, VS Code, Codex) are diagnostics only. Execution drivers today: `claude-code` only.
41
+ ## Grant enforcement
42
+
43
+ Every driver enforces the assignment's granted actions with its CLI's native mechanism — never just the prompt:
44
+
45
+ | Driver | Fuel | Mechanism |
46
+ | --- | --- | --- |
47
+ | `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per grant; bounded verification commands |
48
+ | `codex` | pump or local | sandbox `read-only` / `workspace-write`; network enabled only with `pr_create`; resumes by thread id |
49
+ | `opencode` | pump or local | `plan` agent (no edit/bash) for read-only, `build` for write; `--session` resume; `--format json` |
50
+ | `cursor` | local only | per-run `.cursor/cli.json` allowlist (`approvalMode: allowlist`, shared deny list, no `--force`); read-only runs in `--mode plan` |
51
+ | `kiro` | local only | `--trust-tools=fs_read,fs_write,execute_bash` allowlist; `--no-interactive`; `--resume-id`. Tool-level only — no per-command scoping, so a bash grant trusts `execute_bash` wholesale (Antigravity-tier) |
52
+ | `antigravity` | local only | `-p --mode plan` (read-only) / `--mode accept-edits --sandbox` (write). Mode-level, coarser than per-tool; plain-text output; resume not surfaced |
53
+
54
+ Every driver refuses to start when grants map to nothing, fails closed on missing logins, and shares one denied-command list (merge, force-push, deploy, kubectl, terraform).
55
+
56
+ **Verification status:**
57
+ - `claude-code`, `codex`, `cursor`, `opencode`, `antigravity` — verified end-to-end against their real CLIs (agy 1.1.3: `-p --mode` args, Google login, plain-text output confirmed through the driver).
58
+ - `kiro` — flag surface and login preflight verified against kiro-cli 2.13.0 (`chat --no-interactive --trust-tools --resume-id`; tool names; `whoami`-based login check that avoids the browser-login hang). A full run is **pending a `KIRO_API_KEY`** — kiro-cli requires auth to execute and cannot be exercised offline.
package/dist/cli.js CHANGED
@@ -17,6 +17,8 @@ const BRIDGE_NPX = "npx @miraland-labs/conduit-bridge";
17
17
  function bridgeUsage(...args) {
18
18
  return `${BRIDGE_NPX} ${args.join(" ")}`;
19
19
  }
20
+ /** Single source for the --agent placeholder so CLI help never drifts from DRIVERS. */
21
+ const AGENT_PLACEHOLDER = `<${Object.keys(DRIVERS).join("|")}>`;
20
22
  function parseFuelSource(value) {
21
23
  if (value === undefined)
22
24
  return undefined;
@@ -85,7 +87,7 @@ async function join() {
85
87
  console.log(`Assignments at once: ${leaseCapacity}`);
86
88
  if (fuelSource)
87
89
  console.log(`Fuel source: ${fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
88
- console.log("Execution today requires Claude Code (`claude` on PATH). Other detected clients are diagnostics only.");
90
+ console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/kiro/antigravity require local fuel).`);
89
91
  console.log("Use --machine <name> to override the computer label.\n");
90
92
  const response = await fetch(`${baseUrl}/runner/v1/connect/requests`, {
91
93
  method: "POST",
@@ -193,9 +195,9 @@ async function finishConnection(baseUrl, data, fuelSource) {
193
195
  console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
194
196
  console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
195
197
  console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
196
- console.log(`Execute work: ${bridgeUsage("runner", "--agent", "claude-code", "--workspace", "<repo>")}`);
197
- console.log(`Keep on shift (macOS/Linux): ${bridgeUsage("install-service", "--agent", "claude-code", "--workspace", "<repo>")}`);
198
- console.log(`Flip fuel later: ${bridgeUsage("fuel", "local|conduit")}`);
198
+ console.log(`Execute work: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
199
+ console.log(`Keep on shift (macOS/Linux): ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
200
+ console.log(`Flip fuel later: ${bridgeUsage("fuel", "local|conduit")} (cursor/kiro/antigravity require local fuel)`);
199
201
  console.log("Windows: keep the runner terminal open — install-service is macOS/Linux only.");
200
202
  if (Object.keys(fuel).length) {
201
203
  console.log(`Fleet fueling: ${Object.keys(fuel).length} project key(s) configured for Conduit /v1`);
@@ -223,7 +225,7 @@ async function installService() {
223
225
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, "agent-timeout-minutes": { type: "string" },
224
226
  } });
225
227
  if (!values.agent || !values.workspace) {
226
- throw new Error(`Usage: ${bridgeUsage("install-service", "--agent", "claude-code", "--workspace", "<repository-path>")} (agent + workspace required; heartbeat-only services are not supported)`);
228
+ throw new Error(`Usage: ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repository-path>")} (agent + workspace required; heartbeat-only services are not supported)`);
227
229
  }
228
230
  if (!DRIVERS[values.agent]) {
229
231
  throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
@@ -279,7 +281,7 @@ async function runner() {
279
281
  const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
280
282
  const fuelLabel = config.fuelSource === "local" ? "local subscription" : "Conduit pump";
281
283
  if (!driver || !workspace) {
282
- console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", "claude-code", "--workspace", "<repo>")}`);
284
+ console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
283
285
  }
284
286
  console.log(`Conduit runner connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel})`);
285
287
  for (;;) {
package/dist/detect.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import { access, constants } from "node:fs/promises";
2
2
  import { delimiter, join } from "node:path";
3
+ /** PATH probes for Connect diagnostics — not proof of a Bridge driver. */
3
4
  const CLIENTS = [
4
- { command: "codex", label: "Codex CLI" },
5
5
  { command: "claude", label: "Claude Code" },
6
+ { command: "codex", label: "Codex CLI" },
7
+ { command: "opencode", label: "OpenCode" },
8
+ { command: "agent", label: "Cursor Agent" },
6
9
  { command: "cursor", label: "Cursor" },
10
+ { command: "kiro-cli", label: "Kiro CLI" },
11
+ { command: "agy", label: "Antigravity" },
7
12
  { command: "code", label: "Visual Studio Code" },
8
13
  ];
9
14
  export async function detectInstalledClients(pathValue = process.env.PATH ?? "", platform = process.platform) {
package/dist/driver.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { z } from "zod";
5
6
  export const evidenceKinds = ["change", "test", "preview", "research", "documentation"];
@@ -20,11 +21,13 @@ export function claudeToolsForGrants(grants, verificationCommands = []) {
20
21
  tools.push("Bash(git push:*)", "Bash(gh pr create:*)");
21
22
  return tools;
22
23
  }
23
- export const claudeDeniedTools = [
24
- "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git reset --hard:*)", "Bash(git push --force:*)",
25
- "Bash(npm run deploy:*)", "Bash(pnpm deploy:*)", "Bash(yarn deploy:*)", "Bash(wrangler deploy:*)",
26
- "Bash(kubectl:*)", "Bash(terraform apply:*)",
24
+ /** Commands no driver may ever run, regardless of grants — one source for every driver's deny list. */
25
+ export const deniedCommands = [
26
+ "git merge", "git rebase", "git reset --hard", "git push --force",
27
+ "npm run deploy", "pnpm deploy", "yarn deploy", "wrangler deploy",
28
+ "kubectl", "terraform apply",
27
29
  ];
30
+ export const claudeDeniedTools = deniedCommands.map((command) => `Bash(${command}:*)`);
28
31
  function isBoundedVerificationCommand(command) {
29
32
  return /^(npm run (verify|typecheck|lint|test|build)|pnpm (verify|typecheck|lint|test|build)|yarn (verify|typecheck|lint|test|build)|cargo (test|check)|go test(?: \.\/\.\.\.)?|make (test|check))$/.test(command);
30
33
  }
@@ -112,12 +115,12 @@ export const claudeCodeDriver = {
112
115
  name: "claude-code",
113
116
  async run(input) {
114
117
  const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
115
- if (fuelSource === "local" && !hasLocalVendorLogin()) {
118
+ if (fuelSource === "local" && !hasClaudeLogin()) {
116
119
  return {
117
120
  status: "failed",
118
121
  resultText: null,
119
122
  sessionId: null,
120
- error: "machine set to local fuel but claude-code has no login",
123
+ error: "machine set to local fuel but claude-code has no login (ANTHROPIC_* or ~/.claude)",
121
124
  };
122
125
  }
123
126
  const tools = claudeToolsForGrants(input.grants, input.verificationCommands);
@@ -153,22 +156,436 @@ export const claudeCodeDriver = {
153
156
  return { status: "completed", resultText: message.result ?? "", sessionId };
154
157
  },
155
158
  };
156
- export const DRIVERS = { "claude-code": claudeCodeDriver };
157
- function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit") {
159
+ /** Map Conduit grants Codex sandbox (privileged grants never widen the sandbox). */
160
+ export function codexSandboxForGrants(grants) {
161
+ if (grants.includes("repo_write") || grants.includes("branch_create") || grants.includes("pr_create") || grants.includes("test_run")) {
162
+ return "workspace-write";
163
+ }
164
+ if (grants.includes("repo_read"))
165
+ return "read-only";
166
+ return null;
167
+ }
168
+ /**
169
+ * Codex exec arguments. The sandbox is the enforcement boundary: network stays
170
+ * off inside workspace-write unless the assignment actually holds pr_create.
171
+ */
172
+ export function codexExecArgs(input, sandbox) {
173
+ // exec is non-interactive by design (no approval flag), and `exec resume`
174
+ // takes the sandbox only as a -c override, not --sandbox.
175
+ const args = input.resumeSessionId
176
+ ? ["exec", "resume", "--json", "-c", `sandbox_mode="${sandbox}"`]
177
+ : ["exec", "--json", "--sandbox", sandbox];
178
+ if (sandbox === "workspace-write" && input.grants.includes("pr_create")) {
179
+ args.push("-c", "sandbox_workspace_write.network_access=true");
180
+ }
181
+ if (input.resumeSessionId)
182
+ args.push(input.resumeSessionId);
183
+ args.push("-");
184
+ return args;
185
+ }
186
+ /**
187
+ * Since the July 2026 desktop merge, macOS machines often carry the codex CLI
188
+ * only inside the ChatGPT app bundle rather than on PATH.
189
+ */
190
+ export const CODEX_APP_BUNDLE_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex";
191
+ export function resolveCodexExecutable(env = process.env, platform = process.platform) {
192
+ const onPath = (env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, "codex")));
193
+ if (onPath)
194
+ return "codex";
195
+ if (platform === "darwin" && existsSync(CODEX_APP_BUNDLE_BINARY))
196
+ return CODEX_APP_BUNDLE_BINARY;
197
+ return "codex";
198
+ }
199
+ /** Pull the thread id and final agent message out of `codex exec --json` JSONL events. */
200
+ export function parseCodexJsonl(stdout) {
201
+ let sessionId = null;
202
+ let resultText = null;
203
+ for (const line of stdout.split("\n")) {
204
+ const trimmed = line.trim();
205
+ if (!trimmed.startsWith("{"))
206
+ continue;
207
+ let event;
208
+ try {
209
+ event = JSON.parse(trimmed);
210
+ }
211
+ catch {
212
+ continue;
213
+ }
214
+ if (typeof event.thread_id === "string")
215
+ sessionId = event.thread_id;
216
+ if (event.item?.type === "agent_message" && typeof event.item.text === "string")
217
+ resultText = event.item.text;
218
+ }
219
+ return { sessionId, resultText };
220
+ }
221
+ export const codexDriver = {
222
+ name: "codex",
223
+ async run(input) {
224
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
225
+ if (fuelSource === "local" && !hasOpenAiLogin()) {
226
+ return {
227
+ status: "failed",
228
+ resultText: null,
229
+ sessionId: null,
230
+ error: "machine set to local fuel but codex has no login (OPENAI_API_KEY / CODEX_API_KEY / ~/.codex)",
231
+ };
232
+ }
233
+ const sandbox = codexSandboxForGrants(input.grants);
234
+ if (!sandbox) {
235
+ return {
236
+ status: "failed",
237
+ resultText: null,
238
+ sessionId: null,
239
+ error: "No Bridge-mapped Codex sandbox for active grants; refusing to start agent",
240
+ };
241
+ }
242
+ // Prompt via stdin avoids ARG_MAX limits on large assignment contracts.
243
+ const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs(input, sandbox), input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
244
+ const parsed = parseCodexJsonl(stdout);
245
+ const resultText = parsed.resultText ?? (stdout || null);
246
+ if (code !== 0) {
247
+ return { status: "failed", resultText, sessionId: parsed.sessionId, error: (stderr || stdout || `codex exited with code ${code}`).slice(0, 20_000) };
248
+ }
249
+ return { status: "completed", resultText, sessionId: parsed.sessionId };
250
+ },
251
+ };
252
+ /**
253
+ * Grants → Cursor CLI permissions (project .cursor/cli.json). With
254
+ * approvalMode "allowlist" and no --force, any command outside this list is
255
+ * denied in print mode — the same fail-closed contract as Claude's tool lists.
256
+ */
257
+ export function cursorPermissionsForGrants(grants, verificationCommands = []) {
258
+ const allow = [];
259
+ if (grants.includes("test_run")) {
260
+ allow.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
261
+ }
262
+ if (grants.includes("branch_create")) {
263
+ allow.push("Shell(git status)", "Shell(git diff)", "Shell(git add)", "Shell(git commit)", "Shell(git branch)", "Shell(git switch)");
264
+ }
265
+ if (grants.includes("pr_create"))
266
+ allow.push("Shell(git push)", "Shell(gh pr create)");
267
+ return { allow, deny: deniedCommands.map((command) => `Shell(${command})`) };
268
+ }
269
+ /** Cursor CLI arguments. Read-only assignments run in plan mode — edits are impossible, not just discouraged. */
270
+ export function cursorRunArgs(input) {
271
+ const args = ["-p", "--output-format", "json", "--workspace", input.workspace];
272
+ const canChange = ["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => input.grants.includes(grant));
273
+ if (!canChange)
274
+ args.push("--mode", "plan");
275
+ if (input.resumeSessionId)
276
+ args.push("--resume", input.resumeSessionId);
277
+ args.push(input.prompt);
278
+ return args;
279
+ }
280
+ /** Tolerant parse of `--output-format json`: accept the documented shape, fall back to raw text. */
281
+ export function parseCursorOutput(stdout) {
282
+ try {
283
+ const parsed = JSON.parse(stdout);
284
+ const resultText = typeof parsed.result === "string" ? parsed.result : typeof parsed.text === "string" ? parsed.text : stdout;
285
+ const sessionCandidate = [parsed.session_id, parsed.chat_id, parsed.chatId].find((value) => typeof value === "string");
286
+ return { resultText, sessionId: sessionCandidate ?? null, isError: parsed.is_error === true || parsed.subtype === "error" };
287
+ }
288
+ catch {
289
+ return { resultText: stdout || null, sessionId: null, isError: false };
290
+ }
291
+ }
292
+ export const cursorDriver = {
293
+ name: "cursor",
294
+ async run(input) {
295
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
296
+ // Cursor Agent authenticates with CURSOR_API_KEY / IDE login — not Conduit /v1 Anthropic/OpenAI shims.
297
+ if (fuelSource === "conduit") {
298
+ return {
299
+ status: "failed",
300
+ resultText: null,
301
+ sessionId: null,
302
+ error: "cursor driver requires local fuel (CURSOR_API_KEY or Cursor login). Run: npx @miraland-labs/conduit-bridge fuel local",
303
+ };
304
+ }
305
+ if (!input.grants.includes("repo_read") && !input.grants.includes("repo_write")) {
306
+ return {
307
+ status: "failed",
308
+ resultText: null,
309
+ sessionId: null,
310
+ error: "No Bridge-mapped Cursor access for active grants; refusing to start agent",
311
+ };
312
+ }
313
+ const executable = input.executable ?? "agent";
314
+ // A ~/.cursor directory exists even after logout — ask the CLI itself.
315
+ // `agent status` exits 0 either way, so the text is the signal.
316
+ if (!process.env.CURSOR_API_KEY) {
317
+ const status = await execute(executable, ["status"], input.workspace, 15_000, undefined, "local");
318
+ if (status.code !== 0 || /not logged in/i.test(status.stdout)) {
319
+ return { status: "failed", resultText: null, sessionId: null, error: "cursor has no login (set CURSOR_API_KEY or run `agent login`)" };
320
+ }
321
+ }
322
+ const configured = await withCursorPermissions(input.workspace, cursorPermissionsForGrants(input.grants, input.verificationCommands), () => execute(executable, cursorRunArgs(input), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
323
+ const { code, stdout, stderr } = configured;
324
+ const parsed = parseCursorOutput(stdout);
325
+ if (code !== 0 || parsed.isError) {
326
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `cursor agent exited with code ${code}`).slice(0, 20_000) };
327
+ }
328
+ return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
329
+ },
330
+ };
331
+ /** Write the assignment's permission contract to .cursor/cli.json for the run, restoring whatever was there before. */
332
+ async function withCursorPermissions(workspace, permissions, run) {
333
+ const configDir = join(workspace, ".cursor");
334
+ const configPath = join(configDir, "cli.json");
335
+ let previous = null;
336
+ try {
337
+ previous = await readFile(configPath, "utf8");
338
+ }
339
+ catch {
340
+ previous = null;
341
+ }
342
+ let config = { version: 1 };
343
+ if (previous) {
344
+ try {
345
+ config = { ...JSON.parse(previous) };
346
+ }
347
+ catch {
348
+ config = { version: 1 };
349
+ }
350
+ }
351
+ config.permissions = permissions;
352
+ config.approvalMode = "allowlist";
353
+ await mkdir(configDir, { recursive: true });
354
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
355
+ try {
356
+ return await run();
357
+ }
358
+ finally {
359
+ if (previous === null)
360
+ await rm(configPath, { force: true });
361
+ else
362
+ await writeFile(configPath, previous, "utf8");
363
+ }
364
+ }
365
+ /**
366
+ * OpenCode routes through any OpenAI/Anthropic-compatible endpoint, so it runs
367
+ * on Conduit pump fuel or a local login. Read-only assignments use its `plan`
368
+ * agent (no edit/bash tools); write assignments use `build`.
369
+ */
370
+ export function openCodeAgentForGrants(grants) {
371
+ if (["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
372
+ return "build";
373
+ if (grants.includes("repo_read"))
374
+ return "plan";
375
+ return null;
376
+ }
377
+ export function openCodeRunArgs(input, agent) {
378
+ const args = ["run", "--format", "json", "--agent", agent];
379
+ // plan can never write, so --auto only widens the already-bounded build agent.
380
+ if (agent === "build")
381
+ args.push("--auto");
382
+ if (input.resumeSessionId)
383
+ args.push("--session", input.resumeSessionId);
384
+ return args;
385
+ }
386
+ /**
387
+ * OpenCode `--format json` streams JSONL events. Verified against opencode 1.18:
388
+ * assistant text arrives as `{type:"text", part:{type:"text", text:"…"}}`, the
389
+ * session id is `sessionID` on every event, and failures are `{type:"error",
390
+ * error:{…}}`. Concatenate text parts so the delivery report's fenced block
391
+ * survives multi-step runs.
392
+ */
393
+ export function parseOpenCodeOutput(stdout) {
394
+ const texts = [];
395
+ let sessionId = null;
396
+ let isError = false;
397
+ let errorMessage = null;
398
+ const whole = stdout.trim();
399
+ for (const line of whole.split("\n")) {
400
+ const trimmed = line.trim();
401
+ if (!trimmed.startsWith("{"))
402
+ continue;
403
+ let event;
404
+ try {
405
+ event = JSON.parse(trimmed);
406
+ }
407
+ catch {
408
+ continue;
409
+ }
410
+ const session = [event.sessionID, event.session_id].find((value) => typeof value === "string");
411
+ if (typeof session === "string")
412
+ sessionId = session;
413
+ if (event.type === "text" && event.part?.type === "text" && typeof event.part.text === "string")
414
+ texts.push(event.part.text);
415
+ if (event.type === "error") {
416
+ isError = true;
417
+ const err = event.error;
418
+ errorMessage = typeof err === "string" ? err : (typeof err?.data?.message === "string" ? err.data.message : typeof err?.message === "string" ? err.message : null);
419
+ }
420
+ }
421
+ const resultText = texts.length ? texts.join("\n") : (isError && errorMessage ? errorMessage : (whole || null));
422
+ return { resultText, sessionId, isError };
423
+ }
424
+ export const openCodeDriver = {
425
+ name: "opencode",
426
+ async run(input) {
427
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
428
+ if (fuelSource === "local" && !hasOpenCodeLogin()) {
429
+ return { status: "failed", resultText: null, sessionId: null, error: "machine set to local fuel but opencode has no login (auth.json / provider key in ~/.local/share/opencode or ~/.config/opencode)" };
430
+ }
431
+ const agent = openCodeAgentForGrants(input.grants);
432
+ if (!agent) {
433
+ return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped OpenCode agent for active grants; refusing to start agent" };
434
+ }
435
+ const args = openCodeRunArgs(input, agent);
436
+ args.push(input.prompt);
437
+ const { code, stdout, stderr } = await execute(input.executable ?? "opencode", args, input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
438
+ const parsed = parseOpenCodeOutput(stdout);
439
+ if (code !== 0 || parsed.isError) {
440
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `opencode exited with code ${code}`).slice(0, 20_000) };
441
+ }
442
+ return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
443
+ },
444
+ };
445
+ /**
446
+ * Kiro CLI grant map (verified flag surface against kiro-cli 2.13.0).
447
+ * `--trust-tools` takes a comma-separated allowlist of tool NAMES; anything
448
+ * outside it prompts, which in `--no-interactive` mode is a hard stop — the
449
+ * fail-closed contract. Tool names confirmed from the CLI help: fs_read,
450
+ * fs_write, execute_bash. Note the coarseness: kiro-cli's --trust-tools has no
451
+ * per-command scoping, so any bash-requiring grant trusts execute_bash wholesale
452
+ * (Antigravity-tier, not Claude-tier). Fine-grained command allowlisting would
453
+ * need a kiro agent config file — deferred until it can be verified with a login.
454
+ */
455
+ export function kiroTrustedTools(grants) {
456
+ const tools = [];
457
+ if (grants.includes("repo_read"))
458
+ tools.push("fs_read");
459
+ if (grants.includes("repo_write"))
460
+ tools.push("fs_write");
461
+ if (["test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
462
+ tools.push("execute_bash");
463
+ return tools;
464
+ }
465
+ export function kiroChatArgs(input, trustedTools) {
466
+ const args = ["chat", "--no-interactive"];
467
+ if (input.resumeSessionId)
468
+ args.push("--resume-id", input.resumeSessionId);
469
+ // Fail closed: an empty allowlist would trust nothing, but we already refused
470
+ // upstream; passing the explicit list keeps ungranted tools blocked.
471
+ args.push("--trust-tools", trustedTools.join(","));
472
+ return args;
473
+ }
474
+ export const kiroDriver = {
475
+ name: "kiro",
476
+ async run(input) {
477
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
478
+ // Kiro authenticates with KIRO_API_KEY or Builder ID login — not Conduit /v1 shims.
479
+ if (fuelSource === "conduit") {
480
+ return { status: "failed", resultText: null, sessionId: null, error: "kiro driver requires local fuel (KIRO_API_KEY or `kiro-cli login`). Run: npx @miraland-labs/conduit-bridge fuel local" };
481
+ }
482
+ const trusted = kiroTrustedTools(input.grants);
483
+ if (!trusted.length) {
484
+ return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Kiro tools for active grants; refusing to start agent" };
485
+ }
486
+ const executable = input.executable ?? "kiro-cli";
487
+ // ~/.kiro exists even when logged out, so ask the CLI. `whoami` exits non-zero
488
+ // and prints "Not logged in" when unauthenticated — check before the run so we
489
+ // never hang on the interactive browser login in --no-interactive mode.
490
+ if (!hasKiroLogin()) {
491
+ const who = await execute(executable, ["whoami"], input.workspace, 15_000, undefined, "local");
492
+ if (who.code !== 0 || /not logged in/i.test(who.stdout + who.stderr)) {
493
+ return { status: "failed", resultText: null, sessionId: null, error: "kiro has no login (set KIRO_API_KEY or run `kiro-cli login`)" };
494
+ }
495
+ }
496
+ const args = kiroChatArgs(input, trusted);
497
+ args.push(input.prompt);
498
+ const { code, stdout, stderr } = await execute(executable, args, input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
499
+ if (code !== 0) {
500
+ return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `kiro-cli exited with code ${code}`).slice(0, 20_000) };
501
+ }
502
+ return { status: "completed", resultText: stdout, sessionId: null };
503
+ },
504
+ };
505
+ /**
506
+ * Antigravity's `agy` CLI (verified against agy 1.1.3): non-interactive runs use
507
+ * `-p`/`--print` with `--mode plan` (read-only) or `--mode accept-edits`. Its
508
+ * enforcement is mode-level, not per-tool — coarser than Claude, the Cursor tier.
509
+ * Write mode adds `--sandbox` for terminal restrictions. Output is plain text.
510
+ */
511
+ export function antigravityModeForGrants(grants) {
512
+ if (["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
513
+ return "accept-edits";
514
+ if (grants.includes("repo_read"))
515
+ return "plan";
516
+ return null;
517
+ }
518
+ export function antigravityRunArgs(input, mode) {
519
+ const args = ["-p", "--mode", mode];
520
+ if (mode === "accept-edits")
521
+ args.push("--sandbox");
522
+ args.push(input.prompt);
523
+ return args;
524
+ }
525
+ export const antigravityDriver = {
526
+ name: "antigravity",
527
+ async run(input) {
528
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
529
+ // Antigravity authenticates with a Google login / GEMINI_API_KEY — not Conduit /v1 shims.
530
+ if (fuelSource === "conduit") {
531
+ return { status: "failed", resultText: null, sessionId: null, error: "antigravity driver requires local fuel (GEMINI_API_KEY or `agy` Google login). Run: npx @miraland-labs/conduit-bridge fuel local" };
532
+ }
533
+ if (!hasAntigravityLogin()) {
534
+ return { status: "failed", resultText: null, sessionId: null, error: "antigravity has no login (set GEMINI_API_KEY or sign in with `agy`)" };
535
+ }
536
+ const mode = antigravityModeForGrants(input.grants);
537
+ if (!mode) {
538
+ return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
539
+ }
540
+ // agy print mode emits plain text and does not surface a resumable id, so rework resume is not wired.
541
+ const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
542
+ if (code !== 0) {
543
+ return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `agy exited with code ${code}`).slice(0, 20_000) };
544
+ }
545
+ return { status: "completed", resultText: stdout, sessionId: null };
546
+ },
547
+ };
548
+ /** Stable driver ids shown in Connect / CLI. Order is product preference, not exclusivity. */
549
+ export const SUPPORTED_AGENTS = [
550
+ { id: "claude-code", label: "Claude Code", executableHint: "claude" },
551
+ { id: "codex", label: "Codex (ChatGPT)", executableHint: "codex" },
552
+ { id: "cursor", label: "Cursor Agent", executableHint: "agent" },
553
+ { id: "opencode", label: "OpenCode", executableHint: "opencode" },
554
+ { id: "kiro", label: "Kiro CLI", executableHint: "kiro-cli" },
555
+ { id: "antigravity", label: "Antigravity (agy)", executableHint: "agy" },
556
+ ];
557
+ export const DRIVERS = {
558
+ "claude-code": claudeCodeDriver,
559
+ codex: codexDriver,
560
+ cursor: cursorDriver,
561
+ opencode: openCodeDriver,
562
+ kiro: kiroDriver,
563
+ antigravity: antigravityDriver,
564
+ };
565
+ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit", stdinText) {
158
566
  return new Promise((resolve, reject) => {
159
- const child = spawn(executable, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: boundedEnvironment(fuel, fuelSource) });
567
+ const child = spawn(executable, args, {
568
+ cwd,
569
+ stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
570
+ env: boundedEnvironment(fuel, fuelSource),
571
+ });
160
572
  let stdout = "";
161
573
  let stderr = "";
162
574
  const timer = setTimeout(() => { child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 10_000).unref(); }, timeoutMs);
163
- child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
164
- child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
575
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
576
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
165
577
  child.on("error", (error) => { clearTimeout(timer); reject(error); });
166
578
  child.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr: stderr.slice(0, 20_000) }); });
579
+ if (stdinText !== undefined && child.stdin) {
580
+ child.stdin.write(stdinText);
581
+ child.stdin.end();
582
+ }
167
583
  });
168
584
  }
169
585
  const LOCAL_VENDOR_ENV = [
170
586
  "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
171
- "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE",
587
+ "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE", "CODEX_API_KEY",
588
+ "CURSOR_API_KEY", "KIRO_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
172
589
  ];
173
590
  function boundedEnvironment(fuel, fuelSource = "conduit") {
174
591
  // Proxy and TLS variables stay: fueled agents must reach Conduit's /v1 on
@@ -183,17 +600,53 @@ function boundedEnvironment(fuel, fuelSource = "conduit") {
183
600
  env.ANTHROPIC_BASE_URL = v1;
184
601
  env.OPENAI_API_KEY = fuel.gatewayKey;
185
602
  env.OPENAI_BASE_URL = v1;
603
+ env.CODEX_API_KEY = fuel.gatewayKey;
186
604
  }
187
605
  return env;
188
606
  }
189
- /** True when local fuel can use a host vendor login (env key or Claude credentials dir). */
190
- export function hasLocalVendorLogin(env = process.env) {
191
- if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || env.OPENAI_API_KEY)
607
+ export function hasClaudeLogin(env = process.env) {
608
+ if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN)
609
+ return true;
610
+ const home = env.HOME;
611
+ return Boolean(home && existsSync(join(home, ".claude")));
612
+ }
613
+ export function hasOpenAiLogin(env = process.env) {
614
+ if (env.OPENAI_API_KEY || env.CODEX_API_KEY)
615
+ return true;
616
+ const home = env.HOME;
617
+ return Boolean(home && existsSync(join(home, ".codex", "auth.json")));
618
+ }
619
+ export function hasCursorLogin(env = process.env) {
620
+ if (env.CURSOR_API_KEY)
621
+ return true;
622
+ const home = env.HOME;
623
+ return Boolean(home && (existsSync(join(home, ".cursor")) || existsSync(join(home, ".config", "cursor"))));
624
+ }
625
+ export function hasOpenCodeLogin(env = process.env) {
626
+ if (env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY || env.OPENROUTER_API_KEY)
192
627
  return true;
193
628
  const home = env.HOME;
194
629
  if (!home)
195
630
  return false;
196
- return existsSync(join(home, ".claude"));
631
+ return existsSync(join(home, ".local", "share", "opencode", "auth.json")) || existsSync(join(home, ".config", "opencode"));
632
+ }
633
+ export function hasKiroLogin(env = process.env) {
634
+ // Only KIRO_API_KEY is a reliable headless signal: ~/.kiro exists even when
635
+ // logged out (the IDE creates it), so a login is confirmed at spawn via
636
+ // `kiro-cli whoami` rather than a directory probe.
637
+ return Boolean(env.KIRO_API_KEY);
638
+ }
639
+ export function hasAntigravityLogin(env = process.env) {
640
+ if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY)
641
+ return true;
642
+ const home = env.HOME;
643
+ // agy 1.1.3 stores its Google session state under ~/.gemini (shared with the IDE).
644
+ return Boolean(home && (existsSync(join(home, ".gemini")) || existsSync(join(home, ".antigravity"))));
645
+ }
646
+ /** True when local fuel can use a host vendor login for at least one supported agent. */
647
+ export function hasLocalVendorLogin(env = process.env) {
648
+ return hasClaudeLogin(env) || hasOpenAiLogin(env) || hasCursorLogin(env)
649
+ || hasOpenCodeLogin(env) || hasKiroLogin(env) || hasAntigravityLogin(env);
197
650
  }
198
651
  /** Exported for tests — builds the stripped process env with optional Conduit fuel. */
199
652
  export function agentProcessEnv(fuel, fuelSource = "conduit") {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.1.0",
4
- "description": "Conduit Bridge CLI — join, connect, and run local agent work for a Conduit organization",
3
+ "version": "0.4.0",
4
+ "description": "Conduit Bridge CLI — join, connect, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conduit": "dist/cli.js"