@fusionkit/cli 0.1.4 → 0.1.6

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.
Files changed (74) hide show
  1. package/README.md +26 -4
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +4 -17
  4. package/dist/commands/ensemble-gateway.js +0 -2
  5. package/dist/commands/ensemble-records.d.ts +2 -1
  6. package/dist/commands/ensemble-records.js +3 -1
  7. package/dist/commands/ensemble.js +3 -4
  8. package/dist/commands/fusion.js +14 -15
  9. package/dist/commands/local.js +3 -3
  10. package/dist/cursor-acp.d.ts +18 -0
  11. package/dist/cursor-acp.js +206 -0
  12. package/dist/dashboard.d.ts +65 -0
  13. package/dist/dashboard.js +587 -0
  14. package/dist/fusion/env.d.ts +108 -0
  15. package/dist/fusion/env.js +98 -0
  16. package/dist/fusion/observability.d.ts +39 -0
  17. package/dist/fusion/observability.js +227 -0
  18. package/dist/fusion/preflight.d.ts +12 -0
  19. package/dist/fusion/preflight.js +42 -0
  20. package/dist/fusion/stack.d.ts +62 -0
  21. package/dist/fusion/stack.js +295 -0
  22. package/dist/fusion-config.d.ts +0 -1
  23. package/dist/fusion-config.js +0 -6
  24. package/dist/fusion-init.js +2 -11
  25. package/dist/fusion-quickstart.d.ts +11 -222
  26. package/dist/fusion-quickstart.js +57 -759
  27. package/dist/gateway.d.ts +0 -2
  28. package/dist/gateway.js +12 -2
  29. package/dist/local.d.ts +10 -17
  30. package/dist/local.js +50 -116
  31. package/dist/shared/options.d.ts +2 -1
  32. package/dist/shared/options.js +13 -19
  33. package/dist/shared/proc.d.ts +4 -70
  34. package/dist/shared/proc.js +3 -228
  35. package/dist/test/cli.test.js +32 -142
  36. package/dist/test/dashboard.test.d.ts +1 -0
  37. package/dist/test/dashboard.test.js +214 -0
  38. package/dist/test/gateway-e2e.test.js +13 -10
  39. package/dist/test/local.test.js +4 -4
  40. package/dist/tools.d.ts +2 -0
  41. package/dist/tools.js +25 -0
  42. package/package.json +14 -9
  43. package/scope/.next/BUILD_ID +1 -1
  44. package/scope/.next/app-build-manifest.json +12 -12
  45. package/scope/.next/app-path-routes-manifest.json +3 -3
  46. package/scope/.next/build-manifest.json +2 -2
  47. package/scope/.next/prerender-manifest.json +16 -16
  48. package/scope/.next/server/app/_not-found.html +1 -1
  49. package/scope/.next/server/app/_not-found.rsc +1 -1
  50. package/scope/.next/server/app/environments.html +1 -1
  51. package/scope/.next/server/app/environments.rsc +1 -1
  52. package/scope/.next/server/app/index.html +1 -1
  53. package/scope/.next/server/app/index.rsc +1 -1
  54. package/scope/.next/server/app/models.html +1 -1
  55. package/scope/.next/server/app/models.rsc +1 -1
  56. package/scope/.next/server/app-paths-manifest.json +3 -3
  57. package/scope/.next/server/functions-config-manifest.json +2 -2
  58. package/scope/.next/server/pages/404.html +1 -1
  59. package/scope/.next/server/pages/500.html +1 -1
  60. package/scope/.next/server/server-reference-manifest.json +1 -1
  61. package/dist/commands/init.d.ts +0 -2
  62. package/dist/commands/init.js +0 -24
  63. package/dist/commands/lifecycle.d.ts +0 -2
  64. package/dist/commands/lifecycle.js +0 -124
  65. package/dist/commands/plane.d.ts +0 -2
  66. package/dist/commands/plane.js +0 -38
  67. package/dist/commands/run.d.ts +0 -2
  68. package/dist/commands/run.js +0 -149
  69. package/dist/commands/runner.d.ts +0 -2
  70. package/dist/commands/runner.js +0 -33
  71. package/dist/commands/secrets.d.ts +0 -2
  72. package/dist/commands/secrets.js +0 -21
  73. /package/scope/.next/static/{5tnFLuvnSbNZNtqRgoot8 → x7wPUCpgS31-5ZHJkcKsU}/_buildManifest.js +0 -0
  74. /package/scope/.next/static/{5tnFLuvnSbNZNtqRgoot8 → x7wPUCpgS31-5ZHJkcKsU}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -47,7 +47,7 @@ cloud panel (skip with `--yes`). Use `--local` for the on-device MLX panel, or
47
47
  Tired of long flag lines? Scaffold a committed `fusionkit.json`:
48
48
 
49
49
  ```bash
50
- fusionkit fusion init
50
+ fusionkit init
51
51
  ```
52
52
 
53
53
  It records the panel, judge, default tool, and run defaults so the whole team
@@ -60,7 +60,7 @@ config and a dry-run preview with `fusionkit status`.
60
60
  - `fusionkit codex | claude | cursor` — launch that agent backed by the panel.
61
61
  - `fusionkit serve` — just run the gateway and print setup snippets for any tool.
62
62
  - `fusionkit fusion [tool]` — the generic launcher (interactive picker on a TTY).
63
- - `fusionkit fusion init` — scaffold `fusionkit.json` for this repo.
63
+ - `fusionkit init` — scaffold `fusionkit.json` for this repo.
64
64
  - `fusionkit doctor` — check prerequisites with fix hints.
65
65
  - `fusionkit status` — show the effective config and what a run will do.
66
66
 
@@ -73,5 +73,27 @@ tool name; everything after the tool is forwarded to it.
73
73
  - `--observe` boots a local dashboard that streams live trace events. It is a
74
74
  separate app and is not bundled in the npm package; fusionkit prints how to
75
75
  enable it if it isn't available.
76
- - `cursor` needs a built Cursorkit checkout (`--cursor-kit-dir` or
77
- `FUSIONKIT_CURSORKIT_DIR`); fusionkit prints setup guidance if it's missing.
76
+ - `cursor` only needs a logged-in `cursor-agent` CLI; Cursorkit ships bundled
77
+ with this package, so no separate checkout is required.
78
+
79
+ ## Adding a new tool
80
+
81
+ Each coding tool is its own workspace package implementing a single
82
+ `ToolIntegration` (the adapter), so supporting a new tool is additive:
83
+
84
+ 1. Create `packages/tool-<name>/` (copy `packages/tool-codex` as a template). It
85
+ depends on `@fusionkit/tools` for the `ToolIntegration` / `ToolLaunchContext`
86
+ contract, and on `@fusionkit/ensemble` if it also ships a harness adapter.
87
+ 2. Export a `const <name>Tool: ToolIntegration` with:
88
+ - `launch(ctx)` — boot the tool's binary against `ctx.gatewayUrl` (the host
89
+ injects `spawnTool`, portless, teardown, etc. via the context; tool packages
90
+ never import the CLI).
91
+ - `modes` — `"fusion"`, `"local"`, or both.
92
+ - `createHarness` + `harnessKinds` — optional, only if the tool also runs as
93
+ an ensemble harness in the gateway/e2e matrix.
94
+ 3. Register it in [`packages/cli/src/tools.ts`](src/tools.ts) by adding it to the
95
+ `createToolRegistry([...])` list.
96
+
97
+ That single registry entry wires the tool into the `fusionkit <tool>` launcher,
98
+ `fusionkit local <tool>`, the interactive picker, preflight, and (when it has a
99
+ harness) the ensemble gateway — no other switch statements to update.
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import { Command } from "commander";
2
+ import "./tools.js";
2
3
  export declare function buildProgram(): Command;
package/dist/cli.js CHANGED
@@ -1,22 +1,16 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { Command } from "commander";
4
+ import "./tools.js";
4
5
  import { FUSIONKIT_PYPI_VERSION } from "./fusion-quickstart.js";
5
6
  import { registerDoctor } from "./commands/doctor.js";
6
7
  import { registerEnsemble } from "./commands/ensemble.js";
7
8
  import { registerFusion } from "./commands/fusion.js";
8
- import { registerInit } from "./commands/init.js";
9
- import { registerLifecycle } from "./commands/lifecycle.js";
10
9
  import { registerLocal } from "./commands/local.js";
11
- import { registerPlane } from "./commands/plane.js";
12
- import { registerRun } from "./commands/run.js";
13
- import { registerRunner } from "./commands/runner.js";
14
- import { registerSecrets } from "./commands/secrets.js";
15
10
  /**
16
- * Build the `fusionkit` command tree. The global `--dir` option must precede the
17
- * subcommand (`enablePositionalOptions` keeps the launcher commands' passthrough
18
- * unambiguous). Each `register*` helper attaches its command(s) and reads the
19
- * global home directory via `program.opts().dir`.
11
+ * Build the `fusionkit` command tree. `enablePositionalOptions` keeps the
12
+ * launcher commands' passthrough unambiguous (fusionkit's own flags must
13
+ * precede the tool name). Each `register*` helper attaches its command(s).
20
14
  */
21
15
  function cliVersion() {
22
16
  // dist/cli.js -> ../package.json is the published package manifest.
@@ -34,14 +28,7 @@ export function buildProgram() {
34
28
  .name("fusionkit")
35
29
  .description("real model fusion behind your coding agent (codex, claude, cursor)")
36
30
  .version(`@fusionkit/cli ${cliVersion()} (synthesizer: fusionkit@${FUSIONKIT_PYPI_VERSION} from PyPI)`, "-v, --version", "print the CLI (npm) and pinned synthesizer (PyPI) versions")
37
- .option("-d, --dir <dir>", "fusionkit home (default: ./.fusionkit)")
38
31
  .enablePositionalOptions();
39
- registerInit(program);
40
- registerPlane(program);
41
- registerRunner(program);
42
- registerSecrets(program);
43
- registerRun(program);
44
- registerLifecycle(program);
45
32
  registerEnsemble(program);
46
33
  registerLocal(program);
47
34
  registerFusion(program);
@@ -12,7 +12,6 @@ function addCommonGatewayOptions(cmd) {
12
12
  .option("--out <dir>", "output directory")
13
13
  .option("--model <spec>", "panel model mapping ID=MODEL (repeatable)", collect)
14
14
  .option("--judge-model <model>", "model used for judge synthesis")
15
- .option("--cursor-kit-dir <dir>", "Cursorkit repo for cursor scenarios")
16
15
  .option("--timeout-ms <n>", "candidate timeout")
17
16
  .option("--fusion-api-key <key>", "API key for the fusion backend");
18
17
  }
@@ -30,7 +29,6 @@ function gatewayConfig(opts) {
30
29
  timeoutMs,
31
30
  ...(opts.command !== undefined ? { command: opts.command } : {}),
32
31
  ...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
33
- ...(opts.cursorKitDir !== undefined ? { cursorKitDir: resolve(opts.cursorKitDir) } : {}),
34
32
  ...(opts.fusionApiKey !== undefined ? { fusionApiKey: opts.fusionApiKey } : {})
35
33
  };
36
34
  }
@@ -1,4 +1,5 @@
1
- import type { EnsembleRunResult, HarnessAdapter, HarnessSmokeDashboard } from "@fusionkit/ensemble";
1
+ import type { EnsembleRunResult, HarnessAdapter } from "@fusionkit/ensemble";
2
+ import type { HarnessSmokeDashboard } from "../dashboard.js";
2
3
  import type { BenchmarkTaskRecordV1, ModelFusionHarnessKind, ModelFusionRecordV1, ModelFusionSideEffects } from "@fusionkit/protocol";
3
4
  export type HandoffPayload = {
4
5
  category?: string;
@@ -1,6 +1,8 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { claudeCodeHarness, claudeCodeHarnessCredentialSkipReason, codexHarness, codexHarnessCredentialSkipReason, createCommandHarness, createMockHarness } from "@fusionkit/ensemble";
3
+ import { createCommandHarness, createMockHarness } from "@fusionkit/ensemble";
4
+ import { claudeCodeHarness, claudeCodeHarnessCredentialSkipReason } from "@fusionkit/tool-claude";
5
+ import { codexHarness, codexHarnessCredentialSkipReason } from "@fusionkit/tool-codex";
4
6
  import { assertBenchmarkTaskRecordV1, assertHarnessCandidateRecordV1, assertJudgeSynthesisRecordV1, assertModelCallRecordV1, assertModelFusionRecord, assertToolExecutionRecordV1, MODEL_FUSION_SCHEMA_BUNDLE_HASH } from "@fusionkit/protocol";
5
7
  import { gitText } from "@fusionkit/workspace";
6
8
  import { fail } from "../shared/errors.js";
@@ -1,9 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { Command } from "commander";
4
- import { createCommandHarness, createMockHarness, createMockJudgeSynthesizer, runEnsemble, runHarnessSmokeDashboard, runUnifiedHarnessE2E } from "@fusionkit/ensemble";
4
+ import { createCommandHarness, createMockHarness, createMockJudgeSynthesizer, runEnsemble, runUnifiedHarnessE2E } from "@fusionkit/ensemble";
5
5
  import { assertHarnessRunRequestV1, assertHarnessRunResultV1 } from "@fusionkit/protocol";
6
6
  import { gitText } from "@fusionkit/workspace";
7
+ import { runHarnessSmokeDashboard } from "../dashboard.js";
7
8
  import { fail } from "../shared/errors.js";
8
9
  import { collect, ensembleModels, liveSmokeTargets, parseTimeoutMs, unifiedHarnessKinds } from "../shared/options.js";
9
10
  import { buildGatewayCommand } from "./ensemble-gateway.js";
@@ -172,8 +173,7 @@ async function runEnsembleE2E(task, opts) {
172
173
  models,
173
174
  ...(opts.command !== undefined ? { command: opts.command } : {}),
174
175
  timeoutMs,
175
- ...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
176
- ...(opts.cursorKitDir !== undefined ? { cursorKitDir: resolve(opts.cursorKitDir) } : {})
176
+ ...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {})
177
177
  });
178
178
  const counts = new Map();
179
179
  for (const row of result.results) {
@@ -245,7 +245,6 @@ export function registerEnsemble(program) {
245
245
  .option("--id <id>", "descriptor id")
246
246
  .option("--model <spec>", "panel model mapping ID=MODEL (repeatable)", collect)
247
247
  .option("--judge-model <model>", "model used for judge synthesis")
248
- .option("--cursor-kit-dir <dir>", "Cursorkit repo for cursor ACP/desktop scenarios")
249
248
  .option("--timeout-ms <n>", "candidate timeout")
250
249
  .option("--task-file <file>", "read task prompt from file")
251
250
  .action(runEnsembleE2E);
@@ -16,7 +16,6 @@ function applyFusionOptions(command) {
16
16
  .option("--synthesis-url <url>", "pre-running fusionkit serve for synthesis")
17
17
  .option("--fusionkit-dir <dir>", "local FusionKit checkout (dev override for the uvx synthesizer)")
18
18
  .option("--repo <dir>", "coding workspace the panel fuses over")
19
- .option("--cursor-kit-dir <dir>", "built Cursorkit checkout for the cursor tool")
20
19
  .option("--local", "use the local MLX panel trio instead of the default cloud panel")
21
20
  .option("--no-local", "override a fusionkit.json default of local=true")
22
21
  .option("--observe", "boot the local scope dashboard and stream live trace events")
@@ -45,8 +44,6 @@ function resolveOptions(opts) {
45
44
  options.fusionkitDir = resolve(opts.fusionkitDir);
46
45
  if (opts.repo !== undefined)
47
46
  options.repo = resolve(opts.repo);
48
- if (opts.cursorKitDir !== undefined)
49
- options.cursorKitDir = resolve(opts.cursorKitDir);
50
47
  // local/observe are tri-state: only set when the user passed --local/--no-local
51
48
  // (or --observe/--no-observe), so an unset flag can fall through to the config.
52
49
  if (opts.local !== undefined)
@@ -99,8 +96,6 @@ function mergeConfig(options, config) {
99
96
  options.observe = config.observe;
100
97
  if (options.portless === undefined && config.portless !== undefined)
101
98
  options.portless = config.portless;
102
- if (options.cursorKitDir === undefined && config.cursorKitDir != null)
103
- options.cursorKitDir = config.cursorKitDir;
104
99
  if (options.port === undefined && config.port != null)
105
100
  options.port = config.port;
106
101
  }
@@ -129,24 +124,28 @@ function resolveContext(opts) {
129
124
  return { options, ...(config?.tool !== undefined ? { configTool: config.tool } : {}) };
130
125
  }
131
126
  export function registerFusion(program) {
127
+ // Top-level `init` — scaffold a committed fusionkit.json for this repo.
128
+ program
129
+ .command("init")
130
+ .description("scaffold a committed fusionkit.json for this repo")
131
+ .option("--repo <dir>", "coding workspace the panel fuses over")
132
+ .option("--force", "overwrite an existing fusionkit.json")
133
+ .action(async (opts) => {
134
+ const repoRoot = configRepoRoot(resolveOptions(opts));
135
+ const code = await runFusionInit({ repoRoot, force: opts.force === true });
136
+ process.exit(code);
137
+ });
132
138
  // Generic `fusion [tool]` — keeps the original surface and interactive pick.
133
139
  applyFusionOptions(program
134
140
  .command("fusion")
135
141
  .description("one command: real model fusion backs a coding agent")
136
- .argument("[tool]", `${FUSION_TOOLS.join(" | ")} | init | stop (omit on a TTY to pick interactively)`)
142
+ .argument("[tool]", `${FUSION_TOOLS.join(" | ")} | stop (omit on a TTY to pick interactively)`)
137
143
  .argument("[args...]", "arguments forwarded to the tool")
138
- .option("--tool <tool>", `coding agent to launch (${FUSION_TOOLS.join(" | ")})`)
139
- .option("--force", "overwrite an existing fusionkit.json (with `fusion init`)"))
144
+ .option("--tool <tool>", `coding agent to launch (${FUSION_TOOLS.join(" | ")})`))
140
145
  .addHelpText("after", "\nfusionkit's own flags must precede the tool name; everything after the tool is forwarded to it." +
141
- "\nRun `fusionkit fusion init` to scaffold a committed fusionkit.json for this repo." +
146
+ "\nRun `fusionkit init` to scaffold a committed fusionkit.json for this repo." +
142
147
  "\nRun `fusionkit fusion stop` to reap portless singleton services (router, dashboard, ...).")
143
148
  .action(async (positionalTool, args, opts) => {
144
- // `fusion init` scaffolds the per-repo config instead of launching a tool.
145
- if (positionalTool === "init") {
146
- const repoRoot = configRepoRoot(resolveOptions(opts));
147
- const code = await runFusionInit({ repoRoot, force: opts.force === true });
148
- process.exit(code);
149
- }
150
149
  // `fusion stop` reaps persistent portless singletons left running by prior
151
150
  // runs (the router, dashboard, ...).
152
151
  if (positionalTool === "stop") {
@@ -6,14 +6,14 @@ export function registerLocal(program) {
6
6
  .description("back a vendor agent with a local model")
7
7
  .argument("[tool]", `${LOCAL_TOOLS.join(" | ")}`)
8
8
  .argument("[args...]", "arguments forwarded to the tool")
9
- .option("--public-url <url>", "public tunnel URL for Cursor (or WARRANT_PUBLIC_URL)")
9
+ .option("--public-url <url>", "public tunnel URL for Cursor (or FUSIONKIT_PUBLIC_URL)")
10
10
  .option("--auth-token <token>", "require a bearer token on the gateway")
11
11
  .allowUnknownOption()
12
12
  .passThroughOptions()
13
- .addHelpText("after", "\nwarrant's own flags must precede the tool name; everything after the tool is forwarded to it.")
13
+ .addHelpText("after", "\nfusionkit's own flags must precede the tool name; everything after the tool is forwarded to it.")
14
14
  .action(async (tool, args, opts) => {
15
15
  if (tool === undefined || !LOCAL_TOOLS.includes(tool)) {
16
- fail(`usage: warrant local <${LOCAL_TOOLS.join(" | ")}> [args...]`);
16
+ fail(`usage: fusionkit local <${LOCAL_TOOLS.join(" | ")}> [args...]`);
17
17
  }
18
18
  const options = {
19
19
  ...(opts.publicUrl !== undefined ? { publicUrl: opts.publicUrl } : {}),
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Real Cursor ACP front-door producer. Spawns the bundled Cursorkit bridge (its
3
+ * local-model backend pointed at the running Fusion Harness Gateway) and drives
4
+ * the real cursor-agent CLI in ACP mode, asserting the fusion-synthesized
5
+ * sentinel reaches Cursor via session/update. Returns undefined when the
6
+ * cursor-agent CLI is unavailable, so the acceptance suite records the explicit
7
+ * `blocked` / `cursorkit_backend_not_running` outcome instead of a silent pass.
8
+ */
9
+ import type { FrontDoorOutcomeProducer } from "@fusionkit/model-gateway";
10
+ export type CursorAcpProducerInput = {
11
+ gatewayUrl: string;
12
+ sentinel: string;
13
+ repo: string;
14
+ command?: string;
15
+ modelName?: string;
16
+ timeoutMs?: number;
17
+ };
18
+ export declare function buildCursorAcpProducer(input: CursorAcpProducerInput): FrontDoorOutcomeProducer | undefined;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Real Cursor ACP front-door producer. Spawns the bundled Cursorkit bridge (its
3
+ * local-model backend pointed at the running Fusion Harness Gateway) and drives
4
+ * the real cursor-agent CLI in ACP mode, asserting the fusion-synthesized
5
+ * sentinel reaches Cursor via session/update. Returns undefined when the
6
+ * cursor-agent CLI is unavailable, so the acceptance suite records the explicit
7
+ * `blocked` / `cursorkit_backend_not_running` outcome instead of a silent pass.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { delimiter, join } from "node:path";
12
+ import { createInterface } from "node:readline";
13
+ import { resolveCursorkitCli } from "@fusionkit/ensemble";
14
+ import { readEnv } from "@fusionkit/tools";
15
+ function commandOnPath(command) {
16
+ if (command.includes("/"))
17
+ return existsSync(command);
18
+ const pathValue = process.env.PATH ?? "";
19
+ return pathValue
20
+ .split(delimiter)
21
+ .filter((entry) => entry.length > 0)
22
+ .some((dir) => existsSync(join(dir, command)));
23
+ }
24
+ function normalizeModelBaseUrl(gatewayUrl) {
25
+ const trimmed = gatewayUrl.replace(/\/+$/, "");
26
+ return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
27
+ }
28
+ export function buildCursorAcpProducer(input) {
29
+ const command = input.command ?? "cursor-agent";
30
+ // The live Cursor ACP probe drives the real cursor-agent CLI through the
31
+ // bundled Cursorkit bridge, so it stays opt-in: without the live flag the
32
+ // acceptance suite reports this door as `blocked` rather than spawning live
33
+ // tooling (keeping deterministic runs free of credential/CLI dependencies).
34
+ if (readEnv(process.env, "FUSIONKIT_GATEWAY_LIVE_CURSOR") !== "1") {
35
+ return undefined;
36
+ }
37
+ if (!commandOnPath(command)) {
38
+ return undefined;
39
+ }
40
+ return () => runCursorAcpOutcome({ ...input, command });
41
+ }
42
+ async function runCursorAcpOutcome(input) {
43
+ const modelName = input.modelName ?? "local-fusion";
44
+ const bridgePort = 9700 + Math.floor(Math.random() * 250);
45
+ const bridgeEnv = {};
46
+ for (const [key, value] of Object.entries(process.env)) {
47
+ if (value === undefined)
48
+ continue;
49
+ if (key.startsWith("BRIDGE_") || key.startsWith("MODEL_") || key.startsWith("CURSOR_UPSTREAM")) {
50
+ continue;
51
+ }
52
+ bridgeEnv[key] = value;
53
+ }
54
+ Object.assign(bridgeEnv, {
55
+ BRIDGE_PORT: String(bridgePort),
56
+ BRIDGE_ROUTE_INVENTORY: "true",
57
+ CURSOR_UPSTREAM_BASE_URL: "https://api2.cursor.sh",
58
+ MODEL_BASE_URL: normalizeModelBaseUrl(input.gatewayUrl),
59
+ MODEL_API_KEY: "local",
60
+ MODEL_NAME: modelName,
61
+ MODEL_PROVIDER_MODEL: "fusion-panel",
62
+ MODEL_CONTEXT_TOKEN_LIMIT: "128000"
63
+ });
64
+ const { serveCli } = resolveCursorkitCli();
65
+ let bridgeOut = "";
66
+ const bridge = spawn(process.execPath, [serveCli, "serve"], {
67
+ env: bridgeEnv,
68
+ stdio: ["ignore", "pipe", "pipe"]
69
+ });
70
+ bridge.stdout.on("data", (chunk) => {
71
+ bridgeOut += chunk.toString("utf8");
72
+ });
73
+ bridge.stderr.on("data", (chunk) => {
74
+ bridgeOut += chunk.toString("utf8");
75
+ });
76
+ const evidence = [];
77
+ try {
78
+ const deadline = Date.now() + 20_000;
79
+ while (!/bridge listening/.test(bridgeOut) && Date.now() < deadline) {
80
+ await new Promise((resolve) => setTimeout(resolve, 250));
81
+ }
82
+ if (!/bridge listening/.test(bridgeOut)) {
83
+ return {
84
+ id: "cursor-acp",
85
+ status: "failed",
86
+ reason: "cursorkit_bridge_did_not_start",
87
+ evidence
88
+ };
89
+ }
90
+ const acpText = await driveCursorAgentSentinel({
91
+ command: input.command,
92
+ bridgePort,
93
+ modelName,
94
+ cwd: input.repo,
95
+ sentinel: input.sentinel,
96
+ timeoutMs: input.timeoutMs ?? 120_000
97
+ });
98
+ if (acpText.includes(input.sentinel)) {
99
+ evidence.push(input.sentinel);
100
+ return {
101
+ id: "cursor-acp",
102
+ status: "passed",
103
+ request_path: "/agent.v1.AgentService/Run",
104
+ evidence
105
+ };
106
+ }
107
+ return {
108
+ id: "cursor-acp",
109
+ status: "failed",
110
+ reason: "sentinel_not_observed_in_cursor_session_update",
111
+ evidence
112
+ };
113
+ }
114
+ catch (error) {
115
+ return {
116
+ id: "cursor-acp",
117
+ status: "failed",
118
+ reason: error instanceof Error ? error.message : String(error),
119
+ evidence
120
+ };
121
+ }
122
+ finally {
123
+ bridge.kill("SIGTERM");
124
+ }
125
+ }
126
+ async function driveCursorAgentSentinel(input) {
127
+ const acp = spawn(input.command, [
128
+ "--endpoint",
129
+ `http://127.0.0.1:${input.bridgePort}`,
130
+ "--model",
131
+ input.modelName,
132
+ "--mode",
133
+ "ask",
134
+ "acp"
135
+ ], { cwd: input.cwd, stdio: ["pipe", "pipe", "pipe"] });
136
+ let acpText = "";
137
+ let nextId = 1;
138
+ const pending = new Map();
139
+ const rl = createInterface({ input: acp.stdout });
140
+ const send = (method, params) => {
141
+ const id = nextId++;
142
+ acp.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
143
+ return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
144
+ };
145
+ rl.on("line", (line) => {
146
+ let message;
147
+ try {
148
+ message = JSON.parse(line);
149
+ }
150
+ catch {
151
+ return;
152
+ }
153
+ if (message.id !== undefined && message.method === undefined) {
154
+ const waiter = pending.get(Number(message.id));
155
+ if (waiter === undefined)
156
+ return;
157
+ pending.delete(Number(message.id));
158
+ if (message.error !== undefined)
159
+ waiter.reject(message.error);
160
+ else
161
+ waiter.resolve(message.result);
162
+ return;
163
+ }
164
+ if (message.method !== undefined) {
165
+ if (message.method === "session/update")
166
+ acpText += JSON.stringify(message.params);
167
+ if (message.id !== undefined) {
168
+ acp.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { outcome: { outcome: "skipped", reason: "acceptance" } } })}\n`);
169
+ }
170
+ }
171
+ });
172
+ const withTimeout = (promise, ms) => Promise.race([
173
+ promise,
174
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error("ACP step timed out")), ms))
175
+ ]);
176
+ try {
177
+ await withTimeout(send("initialize", {
178
+ protocolVersion: 1,
179
+ clientCapabilities: {
180
+ fs: { readTextFile: false, writeTextFile: false },
181
+ terminal: false
182
+ },
183
+ clientInfo: { name: "fusionkit-acceptance", version: "0.1.0" }
184
+ }), 60_000);
185
+ await withTimeout(send("authenticate", { methodId: "cursor_login" }), 60_000);
186
+ const session = (await withTimeout(send("session/new", { cwd: input.cwd, mcpServers: [] }), 60_000));
187
+ const sessionId = session.sessionId ?? session.session?.id;
188
+ if (sessionId === undefined)
189
+ return acpText;
190
+ await withTimeout(send("session/prompt", {
191
+ sessionId,
192
+ prompt: [
193
+ {
194
+ type: "text",
195
+ text: `Reply with exactly this token and nothing else: ${input.sentinel}`
196
+ }
197
+ ]
198
+ }), input.timeoutMs);
199
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
200
+ return acpText;
201
+ }
202
+ finally {
203
+ rl.close();
204
+ acp.kill("SIGTERM");
205
+ }
206
+ }
@@ -0,0 +1,65 @@
1
+ import type { HarnessRunResultV1, ModelFusionHarnessKind } from "@fusionkit/protocol";
2
+ import type { HarnessAdapter, HarnessCapabilities } from "@fusionkit/ensemble";
3
+ import type { ToolDashboardMetadata } from "@fusionkit/tools";
4
+ /** Dashboard target id (a tool id like "claude-code", or "command"/"mock"). */
5
+ export type HarnessCapabilityTarget = string;
6
+ export type HarnessAvailability = "available" | "credential_gated" | "missing";
7
+ /** A tool id that exposes a live smoke (e.g. "claude-code", "codex", "cursor"). */
8
+ export type HarnessLiveSmokeTarget = string;
9
+ export type HarnessSmokePurpose = "contract" | "credential-skip" | "live" | "missing";
10
+ export type HarnessAdapterReadiness = {
11
+ harnessId: HarnessCapabilityTarget;
12
+ displayName: string;
13
+ contractReadiness: string;
14
+ credentialState: string;
15
+ liveSmoke: string;
16
+ evidence: string[];
17
+ artifactRefs: string[];
18
+ };
19
+ export type HarnessCapabilityMatrixRow = {
20
+ harnessId: HarnessCapabilityTarget;
21
+ harnessKind: ModelFusionHarnessKind;
22
+ displayName: string;
23
+ availability: HarnessAvailability;
24
+ capabilities: HarnessCapabilities;
25
+ notes: string[];
26
+ };
27
+ export type HarnessCapabilityMatrix = {
28
+ capabilities: string[];
29
+ rows: HarnessCapabilityMatrixRow[];
30
+ };
31
+ export type HarnessSmokeOutcome = "success" | "failure" | "missing" | "skipped";
32
+ export type HarnessSmokeRecord = {
33
+ taskId: string;
34
+ harnessId: HarnessCapabilityTarget;
35
+ purpose: HarnessSmokePurpose;
36
+ outcome: HarnessSmokeOutcome;
37
+ result: HarnessRunResultV1;
38
+ resultPath: string;
39
+ };
40
+ export type HarnessSmokeDashboard = {
41
+ outputRoot: string;
42
+ dashboardPath: string;
43
+ matrix: HarnessCapabilityMatrix;
44
+ records: HarnessSmokeRecord[];
45
+ readiness: HarnessAdapterReadiness[];
46
+ };
47
+ export type HarnessSmokeDashboardOptions = {
48
+ repo?: string;
49
+ outputRoot?: string;
50
+ timeoutMs?: number;
51
+ createdAt?: string;
52
+ env?: Record<string, string | undefined>;
53
+ commandSuccess?: string;
54
+ commandFailure?: string;
55
+ liveSmoke?: readonly HarnessLiveSmokeTarget[];
56
+ liveSmokeHarnesses?: Partial<Record<HarnessLiveSmokeTarget, HarnessAdapter>>;
57
+ /** Per-tool dashboard metadata; defaults to the registered tool registry. */
58
+ tools?: readonly ToolDashboardMetadata[];
59
+ };
60
+ export declare function createHarnessCapabilityMatrix(options?: HarnessSmokeDashboardOptions): HarnessCapabilityMatrix;
61
+ export declare function runHarnessSmokeDashboard(options?: HarnessSmokeDashboardOptions): Promise<HarnessSmokeDashboard>;
62
+ export declare const harnessDashboard: {
63
+ readonly capabilities: typeof createHarnessCapabilityMatrix;
64
+ readonly run: typeof runHarnessSmokeDashboard;
65
+ };