@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.
- package/README.md +26 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +4 -17
- package/dist/commands/ensemble-gateway.js +0 -2
- package/dist/commands/ensemble-records.d.ts +2 -1
- package/dist/commands/ensemble-records.js +3 -1
- package/dist/commands/ensemble.js +3 -4
- package/dist/commands/fusion.js +14 -15
- package/dist/commands/local.js +3 -3
- package/dist/cursor-acp.d.ts +18 -0
- package/dist/cursor-acp.js +206 -0
- package/dist/dashboard.d.ts +65 -0
- package/dist/dashboard.js +587 -0
- package/dist/fusion/env.d.ts +108 -0
- package/dist/fusion/env.js +98 -0
- package/dist/fusion/observability.d.ts +39 -0
- package/dist/fusion/observability.js +227 -0
- package/dist/fusion/preflight.d.ts +12 -0
- package/dist/fusion/preflight.js +42 -0
- package/dist/fusion/stack.d.ts +62 -0
- package/dist/fusion/stack.js +295 -0
- package/dist/fusion-config.d.ts +0 -1
- package/dist/fusion-config.js +0 -6
- package/dist/fusion-init.js +2 -11
- package/dist/fusion-quickstart.d.ts +11 -222
- package/dist/fusion-quickstart.js +57 -759
- package/dist/gateway.d.ts +0 -2
- package/dist/gateway.js +12 -2
- package/dist/local.d.ts +10 -17
- package/dist/local.js +50 -116
- package/dist/shared/options.d.ts +2 -1
- package/dist/shared/options.js +13 -19
- package/dist/shared/proc.d.ts +4 -70
- package/dist/shared/proc.js +3 -228
- package/dist/test/cli.test.js +32 -142
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +214 -0
- package/dist/test/gateway-e2e.test.js +13 -10
- package/dist/test/local.test.js +4 -4
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +25 -0
- package/package.json +14 -9
- package/scope/.next/BUILD_ID +1 -1
- package/scope/.next/app-build-manifest.json +12 -12
- package/scope/.next/app-path-routes-manifest.json +3 -3
- package/scope/.next/build-manifest.json +2 -2
- package/scope/.next/prerender-manifest.json +16 -16
- package/scope/.next/server/app/_not-found.html +1 -1
- package/scope/.next/server/app/_not-found.rsc +1 -1
- package/scope/.next/server/app/environments.html +1 -1
- package/scope/.next/server/app/environments.rsc +1 -1
- package/scope/.next/server/app/index.html +1 -1
- package/scope/.next/server/app/index.rsc +1 -1
- package/scope/.next/server/app/models.html +1 -1
- package/scope/.next/server/app/models.rsc +1 -1
- package/scope/.next/server/app-paths-manifest.json +3 -3
- package/scope/.next/server/functions-config-manifest.json +2 -2
- package/scope/.next/server/pages/404.html +1 -1
- package/scope/.next/server/pages/500.html +1 -1
- package/scope/.next/server/server-reference-manifest.json +1 -1
- package/dist/commands/init.d.ts +0 -2
- package/dist/commands/init.js +0 -24
- package/dist/commands/lifecycle.d.ts +0 -2
- package/dist/commands/lifecycle.js +0 -124
- package/dist/commands/plane.d.ts +0 -2
- package/dist/commands/plane.js +0 -38
- package/dist/commands/run.d.ts +0 -2
- package/dist/commands/run.js +0 -149
- package/dist/commands/runner.d.ts +0 -2
- package/dist/commands/runner.js +0 -33
- package/dist/commands/secrets.d.ts +0 -2
- package/dist/commands/secrets.js +0 -21
- /package/scope/.next/static/{5tnFLuvnSbNZNtqRgoot8 → x7wPUCpgS31-5ZHJkcKsU}/_buildManifest.js +0 -0
- /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
|
|
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
|
|
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
|
|
77
|
-
|
|
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
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.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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 {
|
|
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,
|
|
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);
|
package/dist/commands/fusion.js
CHANGED
|
@@ -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(" | ")} |
|
|
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
|
|
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") {
|
package/dist/commands/local.js
CHANGED
|
@@ -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
|
|
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", "\
|
|
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:
|
|
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
|
+
};
|