@fusionkit/cli 0.1.5 → 0.1.7

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 (77) hide show
  1. package/README.md +32 -8
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +1 -0
  4. package/dist/commands/doctor.js +7 -3
  5. package/dist/commands/ensemble-gateway.js +0 -2
  6. package/dist/commands/ensemble-records.d.ts +2 -1
  7. package/dist/commands/ensemble-records.js +3 -1
  8. package/dist/commands/ensemble.js +3 -4
  9. package/dist/commands/fusion.js +16 -13
  10. package/dist/commands/local.js +3 -3
  11. package/dist/cursor-acp.d.ts +3 -5
  12. package/dist/cursor-acp.js +12 -11
  13. package/dist/dashboard.d.ts +65 -0
  14. package/dist/dashboard.js +587 -0
  15. package/dist/fusion/env.d.ts +111 -0
  16. package/dist/fusion/env.js +98 -0
  17. package/dist/fusion/observability.d.ts +39 -0
  18. package/dist/fusion/observability.js +227 -0
  19. package/dist/fusion/preflight.d.ts +12 -0
  20. package/dist/fusion/preflight.js +42 -0
  21. package/dist/fusion/stack.d.ts +66 -0
  22. package/dist/fusion/stack.js +315 -0
  23. package/dist/fusion-config.d.ts +58 -7
  24. package/dist/fusion-config.js +152 -28
  25. package/dist/fusion-init.d.ts +1 -0
  26. package/dist/fusion-init.js +50 -15
  27. package/dist/fusion-quickstart.d.ts +11 -222
  28. package/dist/fusion-quickstart.js +58 -759
  29. package/dist/gateway.d.ts +0 -2
  30. package/dist/gateway.js +0 -2
  31. package/dist/local.d.ts +10 -17
  32. package/dist/local.js +50 -116
  33. package/dist/shared/options.d.ts +2 -1
  34. package/dist/shared/options.js +13 -19
  35. package/dist/shared/proc.d.ts +4 -70
  36. package/dist/shared/proc.js +3 -228
  37. package/dist/test/cli.test.js +11 -6
  38. package/dist/test/dashboard.test.d.ts +1 -0
  39. package/dist/test/dashboard.test.js +214 -0
  40. package/dist/test/fusion-config.test.js +64 -4
  41. package/dist/test/gateway-e2e.test.js +13 -10
  42. package/dist/test/local.test.js +4 -4
  43. package/dist/tools.d.ts +2 -0
  44. package/dist/tools.js +25 -0
  45. package/package.json +14 -9
  46. package/scope/.next/BUILD_ID +1 -1
  47. package/scope/.next/app-build-manifest.json +10 -10
  48. package/scope/.next/app-path-routes-manifest.json +2 -2
  49. package/scope/.next/build-manifest.json +2 -2
  50. package/scope/.next/prerender-manifest.json +13 -13
  51. package/scope/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  52. package/scope/.next/server/app/_not-found.html +1 -1
  53. package/scope/.next/server/app/_not-found.rsc +1 -1
  54. package/scope/.next/server/app/api/environments/route_client-reference-manifest.js +1 -1
  55. package/scope/.next/server/app/api/ingest/route_client-reference-manifest.js +1 -1
  56. package/scope/.next/server/app/api/models/route_client-reference-manifest.js +1 -1
  57. package/scope/.next/server/app/api/replay/route_client-reference-manifest.js +1 -1
  58. package/scope/.next/server/app/api/sessions/[traceId]/route_client-reference-manifest.js +1 -1
  59. package/scope/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -1
  60. package/scope/.next/server/app/api/stream/route_client-reference-manifest.js +1 -1
  61. package/scope/.next/server/app/environments/page_client-reference-manifest.js +1 -1
  62. package/scope/.next/server/app/environments.html +1 -1
  63. package/scope/.next/server/app/environments.rsc +1 -1
  64. package/scope/.next/server/app/index.html +1 -1
  65. package/scope/.next/server/app/index.rsc +1 -1
  66. package/scope/.next/server/app/models/page_client-reference-manifest.js +1 -1
  67. package/scope/.next/server/app/models.html +1 -1
  68. package/scope/.next/server/app/models.rsc +1 -1
  69. package/scope/.next/server/app/page_client-reference-manifest.js +1 -1
  70. package/scope/.next/server/app/sessions/[traceId]/page_client-reference-manifest.js +1 -1
  71. package/scope/.next/server/app-paths-manifest.json +2 -2
  72. package/scope/.next/server/functions-config-manifest.json +2 -2
  73. package/scope/.next/server/pages/404.html +1 -1
  74. package/scope/.next/server/pages/500.html +1 -1
  75. package/scope/.next/server/server-reference-manifest.json +1 -1
  76. /package/scope/.next/static/{vxqImMqlOwssVTua5Facf → BrrtQvnEIgv-OVkaeanKI}/_buildManifest.js +0 -0
  77. /package/scope/.next/static/{vxqImMqlOwssVTua5Facf → BrrtQvnEIgv-OVkaeanKI}/_ssgManifest.js +0 -0
@@ -10,8 +10,13 @@ import { test } from "node:test";
10
10
  import { MODEL_FUSION_SCHEMA_BUNDLE_HASH } from "@fusionkit/protocol";
11
11
  const CLI = fileURLToPath(new URL("../index.js", import.meta.url));
12
12
  const SMOKE_ENV_KEYS = [
13
+ "FUSIONKIT_CLAUDE_SMOKE",
14
+ "FUSIONKIT_CODEX_SMOKE",
15
+ "FUSIONKIT_CURSOR_SMOKE",
16
+ "FUSIONKIT_ENSEMBLE_LIVE_SMOKE",
13
17
  "WARRANT_CLAUDE_SMOKE",
14
18
  "WARRANT_CODEX_SMOKE",
19
+ "WARRANT_CURSOR_SMOKE",
15
20
  "WARRANT_ENSEMBLE_LIVE_SMOKE"
16
21
  ];
17
22
  async function readBody(req) {
@@ -177,12 +182,12 @@ test("gateway acp-registry rejects an unknown action", () => {
177
182
  test("local without a tool prints usage and fails", () => {
178
183
  const result = warrant(["local"]);
179
184
  assert.equal(result.status, 1);
180
- assert.match(result.stderr, /usage: warrant local </);
185
+ assert.match(result.stderr, /usage: fusionkit local </);
181
186
  });
182
187
  test("local rejects an unknown tool", () => {
183
188
  const result = warrant(["local", "frobnicate"]);
184
189
  assert.equal(result.status, 1);
185
- assert.match(result.stderr, /usage: warrant local </);
190
+ assert.match(result.stderr, /usage: fusionkit local </);
186
191
  });
187
192
  test("local help documents the flags-before-tool contract", () => {
188
193
  const result = warrant(["local", "--help"]);
@@ -194,15 +199,15 @@ test("fusion help documents the flags-before-tool contract", () => {
194
199
  assert.equal(result.status, 0);
195
200
  assert.match(result.stdout, /must precede the tool name/);
196
201
  });
197
- test("init scaffolds a fusionkit.json and refuses to clobber without --force", () => {
202
+ test("init scaffolds a .fusionkit/fusion.json and refuses to clobber without --force", () => {
198
203
  const fixture = makeRepo();
199
204
  try {
200
205
  const result = warrant(["init", "--repo", fixture.repo]);
201
206
  assert.equal(result.status, 0, result.stderr);
202
- const configPath = join(fixture.repo, "fusionkit.json");
207
+ const configPath = join(fixture.repo, ".fusionkit", "fusion.json");
203
208
  assert.ok(existsSync(configPath));
204
209
  const config = JSON.parse(readFileSync(configPath, "utf8"));
205
- assert.equal(config.version, "fusionkit.fusion.v1");
210
+ assert.equal(config.version, "fusionkit.fusion.v2");
206
211
  const again = warrant(["init", "--repo", fixture.repo]);
207
212
  assert.equal(again.status, 1);
208
213
  assert.match(again.stderr, /already exists/);
@@ -620,7 +625,7 @@ test("ensemble dashboard rejects unknown live-smoke targets", () => {
620
625
  "--out",
621
626
  fixture.output,
622
627
  "--live-smoke",
623
- "cursor"
628
+ "bogus"
624
629
  ]);
625
630
  assert.equal(result.status, 1);
626
631
  assert.match(result.stderr, /--live-smoke must be/);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,214 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { assertHarnessRunResultV1 } from "@fusionkit/protocol";
7
+ import { gitText } from "@fusionkit/workspace";
8
+ import { createMockHarness } from "@fusionkit/ensemble";
9
+ import { createHarnessCapabilityMatrix, runHarnessSmokeDashboard } from "../dashboard.js";
10
+ function makeRepo() {
11
+ const root = mkdtempSync(join(tmpdir(), "ensemble-dashboard-"));
12
+ const repo = join(root, "repo");
13
+ mkdirSync(repo);
14
+ gitText(repo, ["init", "--quiet", "--initial-branch=main"]);
15
+ gitText(repo, ["config", "user.email", "dashboard@warrant.local"]);
16
+ gitText(repo, ["config", "user.name", "dashboard"]);
17
+ writeFileSync(join(repo, "README.md"), "# dashboard\n");
18
+ gitText(repo, ["add", "-A"]);
19
+ gitText(repo, ["commit", "--quiet", "-m", "init"]);
20
+ return {
21
+ repo,
22
+ outputRoot: join(root, "dashboard-out"),
23
+ cleanup: () => rmSync(root, { recursive: true, force: true })
24
+ };
25
+ }
26
+ test("capability matrix covers Cursor, Claude Code, Codex, command, and mock", () => {
27
+ const matrix = createHarnessCapabilityMatrix({ env: {} });
28
+ const harnessIds = matrix.rows.map((row) => row.harnessId);
29
+ assert.deepEqual(harnessIds, ["codex", "claude-code", "cursor", "command", "mock"]);
30
+ assert.ok(matrix.capabilities.includes("model_override"));
31
+ assert.ok(matrix.capabilities.includes("transcript_capture"));
32
+ assert.ok(matrix.capabilities.includes("diff_capture"));
33
+ assert.ok(matrix.capabilities.includes("tool_loop_capture"));
34
+ assert.ok(matrix.capabilities.includes("patch_apply_visibility"));
35
+ assert.ok(matrix.capabilities.includes("route_model_observation"));
36
+ assert.ok(matrix.capabilities.includes("verification_hint"));
37
+ assert.ok(matrix.capabilities.includes("replay_support"));
38
+ assert.ok(matrix.capabilities.includes("workspace_read"));
39
+ assert.ok(matrix.capabilities.includes("verification"));
40
+ assert.equal(matrix.rows.find((row) => row.harnessId === "cursor")?.availability, "credential_gated");
41
+ assert.equal(matrix.rows.find((row) => row.harnessId === "claude-code")?.harnessKind, "claude_code");
42
+ assert.equal(matrix.rows.find((row) => row.harnessId === "codex")?.harnessKind, "codex");
43
+ });
44
+ test("smoke dashboard writes schema-valid success, failure, skipped, and missing records", async () => {
45
+ const fixture = makeRepo();
46
+ try {
47
+ const dashboard = await runHarnessSmokeDashboard({
48
+ repo: fixture.repo,
49
+ outputRoot: fixture.outputRoot,
50
+ timeoutMs: 1_000,
51
+ createdAt: "2026-06-16T00:00:00.000Z"
52
+ });
53
+ assert.equal(dashboard.records.length, 6);
54
+ assert.equal(existsSync(dashboard.dashboardPath), true);
55
+ for (const record of dashboard.records) {
56
+ assertHarnessRunResultV1(record.result);
57
+ assert.equal(existsSync(record.resultPath), true);
58
+ const written = JSON.parse(readFileSync(record.resultPath, "utf8"));
59
+ assertHarnessRunResultV1(written);
60
+ }
61
+ const statuses = dashboard.records.map((record) => record.result.status).sort();
62
+ assert.deepEqual(statuses, [
63
+ "failed",
64
+ "skipped",
65
+ "skipped",
66
+ "skipped",
67
+ "succeeded",
68
+ "succeeded"
69
+ ]);
70
+ assert.equal(dashboard.records.find((record) => record.taskId === "claude-code-skipped")?.result
71
+ .harness_kind, "claude_code");
72
+ assert.equal(dashboard.records.find((record) => record.taskId === "codex-skipped")?.result.harness_kind, "codex");
73
+ assert.equal(dashboard.records.find((record) => record.taskId === "cursor-skipped")?.result.harness_kind, "cursor");
74
+ assert.equal(dashboard.records.find((record) => record.taskId === "cursor-skipped")?.result.status, "skipped");
75
+ const markdown = readFileSync(dashboard.dashboardPath, "utf8");
76
+ assert.match(markdown, /# HandoffKit Harness Smoke Dashboard/);
77
+ assert.match(markdown, /## Capability Matrix/);
78
+ assert.match(markdown, /## Adapter Readiness/);
79
+ assert.match(markdown, /contract\/mock ready/);
80
+ assert.match(markdown, /credentials missing\/skipped/);
81
+ assert.match(markdown, /live smoke not requested/);
82
+ assert.match(markdown, /command-failure/);
83
+ assert.match(markdown, /cursor-skipped/);
84
+ assert.match(markdown, /harness-run-results\/mock-success\.json/);
85
+ assert.equal(dashboard.readiness.length, 5);
86
+ }
87
+ finally {
88
+ fixture.cleanup();
89
+ }
90
+ });
91
+ test("smoke dashboard only adds live records when explicit smoke env is enabled", async () => {
92
+ const fixture = makeRepo();
93
+ try {
94
+ const dashboard = await runHarnessSmokeDashboard({
95
+ repo: fixture.repo,
96
+ outputRoot: fixture.outputRoot,
97
+ timeoutMs: 1_000,
98
+ createdAt: "2026-06-16T00:00:00.000Z",
99
+ env: {},
100
+ liveSmoke: ["claude-code", "codex"]
101
+ });
102
+ assert.equal(dashboard.records.length, 6);
103
+ assert.equal(dashboard.records.some((record) => record.purpose === "live"), false);
104
+ }
105
+ finally {
106
+ fixture.cleanup();
107
+ }
108
+ });
109
+ test("explicit live smoke without credentials records a failed preflight", async () => {
110
+ const fixture = makeRepo();
111
+ try {
112
+ const dashboard = await runHarnessSmokeDashboard({
113
+ repo: fixture.repo,
114
+ outputRoot: fixture.outputRoot,
115
+ timeoutMs: 1_000,
116
+ createdAt: "2026-06-16T00:00:00.000Z",
117
+ env: { WARRANT_CLAUDE_SMOKE: "1" },
118
+ liveSmoke: ["claude-code"]
119
+ });
120
+ const live = dashboard.records.find((record) => record.taskId === "claude-code-live");
121
+ assert.equal(live?.purpose, "live");
122
+ assert.equal(live?.result.status, "failed");
123
+ assert.match(live?.result.output_summary ?? "", /Explicit live smoke failed before launch/);
124
+ assert.equal(dashboard.readiness.find((row) => row.harnessId === "claude-code")?.liveSmoke, "live smoke failed");
125
+ }
126
+ finally {
127
+ fixture.cleanup();
128
+ }
129
+ });
130
+ test("live smoke readiness reports sanitized local evidence refs", async () => {
131
+ const fixture = makeRepo();
132
+ const privateTranscript = "raw private transcript should not render";
133
+ try {
134
+ const claudeHarness = {
135
+ ...createMockHarness({
136
+ id: "claude-code-live-mock",
137
+ candidates: {
138
+ claude: {
139
+ transcript: privateTranscript,
140
+ artifacts: [
141
+ {
142
+ artifact_id: "claude_safe_log",
143
+ kind: "log",
144
+ hash: `sha256:${"a".repeat(64)}`,
145
+ uri: "file:///tmp/private-claude.log",
146
+ redaction_status: "synthetic"
147
+ },
148
+ {
149
+ artifact_id: "claude_raw_transcript",
150
+ kind: "transcript",
151
+ hash: `sha256:${"b".repeat(64)}`,
152
+ uri: "file:///tmp/raw-claude.txt",
153
+ redaction_status: "raw"
154
+ }
155
+ ]
156
+ }
157
+ }
158
+ }),
159
+ harnessKind: "claude_code"
160
+ };
161
+ const codexHarness = {
162
+ ...createMockHarness({
163
+ id: "codex-live-mock",
164
+ candidates: {
165
+ codex: {
166
+ transcript: "codex private transcript should not render",
167
+ artifacts: [
168
+ {
169
+ artifact_id: "codex_safe_log",
170
+ kind: "log",
171
+ hash: `sha256:${"c".repeat(64)}`,
172
+ uri: "file:///tmp/private-codex.log",
173
+ redaction_status: "synthetic"
174
+ }
175
+ ]
176
+ }
177
+ }
178
+ }),
179
+ harnessKind: "codex"
180
+ };
181
+ const dashboard = await runHarnessSmokeDashboard({
182
+ repo: fixture.repo,
183
+ outputRoot: fixture.outputRoot,
184
+ timeoutMs: 1_000,
185
+ createdAt: "2026-06-16T00:00:00.000Z",
186
+ env: {
187
+ WARRANT_ENSEMBLE_LIVE_SMOKE: "1",
188
+ VERCEL_TOKEN: "vercel-test",
189
+ ANTHROPIC_API_KEY: "anthropic-test",
190
+ CODEX_API_KEY: "codex-test"
191
+ },
192
+ liveSmoke: ["claude-code", "codex"],
193
+ liveSmokeHarnesses: {
194
+ "claude-code": claudeHarness,
195
+ codex: codexHarness
196
+ }
197
+ });
198
+ assert.equal(dashboard.records.length, 8);
199
+ assert.equal(dashboard.records.find((record) => record.taskId === "claude-code-live")?.result.status, "succeeded");
200
+ assert.equal(dashboard.records.find((record) => record.taskId === "codex-live")?.result.status, "succeeded");
201
+ assert.equal(dashboard.readiness.find((row) => row.harnessId === "claude-code")?.liveSmoke, "live smoke passed");
202
+ assert.equal(dashboard.readiness.find((row) => row.harnessId === "codex")?.liveSmoke, "live smoke passed");
203
+ const markdown = readFileSync(dashboard.dashboardPath, "utf8");
204
+ assert.match(markdown, /log:claude_safe_log:sha256/);
205
+ assert.match(markdown, /log:codex_safe_log:sha256/);
206
+ assert.match(markdown, /raw artifact ref\(s\) withheld/);
207
+ assert.equal(markdown.includes(privateTranscript), false);
208
+ assert.equal(markdown.includes("file:///tmp/private-claude.log"), false);
209
+ assert.equal(markdown.includes("file:///tmp/private-codex.log"), false);
210
+ }
211
+ finally {
212
+ fixture.cleanup();
213
+ }
214
+ });
@@ -1,9 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { after, test } from "node:test";
6
- import { FUSION_CONFIG_VERSION, FusionConfigError, fusionConfigPath, loadFusionConfig, parseFusionConfig, writeFusionConfig } from "../fusion-config.js";
6
+ import { FUSION_CONFIG_VERSION, FusionConfigError, fusionConfigPath, fusionPromptPath, legacyFusionConfigPath, loadFusionConfig, parseFusionConfig, readFusionPrompts, writeFusionConfig, writeFusionPrompts } from "../fusion-config.js";
7
7
  const tmpRoots = [];
8
8
  function freshDir() {
9
9
  const dir = mkdtempSync(join(tmpdir(), "fusion-config-"));
@@ -31,6 +31,11 @@ test("parseFusionConfig accepts a valid config", () => {
31
31
  assert.equal(config.observe, true);
32
32
  assert.equal(config.port, 1234);
33
33
  });
34
+ test("parseFusionConfig upgrades a legacy v1 version in memory", () => {
35
+ const config = parseFusionConfig({ version: "fusionkit.fusion.v1", tool: "claude" }, "test");
36
+ assert.equal(config.version, FUSION_CONFIG_VERSION);
37
+ assert.equal(config.tool, "claude");
38
+ });
34
39
  test("parseFusionConfig rejects an unsupported version", () => {
35
40
  assert.throws(() => parseFusionConfig({ version: "nope" }, "test"), FusionConfigError);
36
41
  });
@@ -43,10 +48,10 @@ test("parseFusionConfig rejects a panel entry missing model", () => {
43
48
  test("parseFusionConfig rejects a non-object", () => {
44
49
  assert.throws(() => parseFusionConfig(["not", "an", "object"], "test"), FusionConfigError);
45
50
  });
46
- test("loadFusionConfig returns undefined when the file is absent", () => {
51
+ test("loadFusionConfig returns undefined when no config exists", () => {
47
52
  assert.equal(loadFusionConfig(freshDir()), undefined);
48
53
  });
49
- test("write then load round-trips the config", () => {
54
+ test("write then load round-trips the config through .fusionkit/fusion.json", () => {
50
55
  const dir = freshDir();
51
56
  const config = {
52
57
  version: FUSION_CONFIG_VERSION,
@@ -61,6 +66,7 @@ test("write then load round-trips the config", () => {
61
66
  };
62
67
  const path = writeFusionConfig(dir, config);
63
68
  assert.equal(path, fusionConfigPath(dir));
69
+ assert.ok(path.endsWith(join(".fusionkit", "fusion.json")));
64
70
  const loaded = loadFusionConfig(dir);
65
71
  assert.deepEqual(loaded, config);
66
72
  });
@@ -73,8 +79,62 @@ test("writeFusionConfig refuses to clobber without force, overwrites with it", (
73
79
  const reloaded = loadFusionConfig(dir);
74
80
  assert.equal(reloaded?.tool, "claude");
75
81
  });
82
+ test("writeFusionConfig omits the prompts field from fusion.json", () => {
83
+ const dir = freshDir();
84
+ writeFusionConfig(dir, { version: FUSION_CONFIG_VERSION, tool: "codex", prompts: { judge: "X" } });
85
+ const onDisk = JSON.parse(readFileSync(fusionConfigPath(dir), "utf8"));
86
+ assert.equal("prompts" in onDisk, false);
87
+ });
76
88
  test("loadFusionConfig surfaces invalid JSON as a FusionConfigError", () => {
77
89
  const dir = freshDir();
90
+ writeFusionConfig(dir, { version: FUSION_CONFIG_VERSION, tool: "codex" });
78
91
  writeFileSync(fusionConfigPath(dir), "{ this is not json");
79
92
  assert.throws(() => loadFusionConfig(dir), FusionConfigError);
80
93
  });
94
+ test("prompt overrides are read from .fusionkit/prompts/*.md and attached on load", () => {
95
+ const dir = freshDir();
96
+ writeFusionConfig(dir, { version: FUSION_CONFIG_VERSION, tool: "codex" });
97
+ writeFusionPrompts(dir, { judge: "CUSTOM JUDGE", "trajectory-step": "CUSTOM STEP" });
98
+ const loaded = loadFusionConfig(dir);
99
+ assert.deepEqual(loaded?.prompts, { judge: "CUSTOM JUDGE", "trajectory-step": "CUSTOM STEP" });
100
+ });
101
+ test("empty prompt files are ignored (fall back to built-in defaults)", () => {
102
+ const dir = freshDir();
103
+ writeFusionConfig(dir, { version: FUSION_CONFIG_VERSION, tool: "codex" });
104
+ writeFusionPrompts(dir, { judge: "REAL" });
105
+ // Blank out the file: an empty override means "use the built-in default".
106
+ writeFileSync(fusionPromptPath(dir, "judge"), " \n");
107
+ assert.deepEqual(readFusionPrompts(dir), {});
108
+ assert.equal(loadFusionConfig(dir)?.prompts, undefined);
109
+ });
110
+ test("writeFusionPrompts does not clobber existing files without force", () => {
111
+ const dir = freshDir();
112
+ writeFusionPrompts(dir, { judge: "FIRST" });
113
+ const second = writeFusionPrompts(dir, { judge: "SECOND" });
114
+ assert.deepEqual(second, []);
115
+ assert.equal(readFusionPrompts(dir).judge, "FIRST");
116
+ writeFusionPrompts(dir, { judge: "THIRD" }, { force: true });
117
+ assert.equal(readFusionPrompts(dir).judge, "THIRD");
118
+ });
119
+ test("loadFusionConfig auto-migrates a legacy fusionkit.json into .fusionkit/", () => {
120
+ const dir = freshDir();
121
+ const legacy = { version: FUSION_CONFIG_VERSION, tool: "claude", local: true };
122
+ writeFileSync(legacyFusionConfigPath(dir), JSON.stringify(legacy, null, 2) + "\n");
123
+ const notices = [];
124
+ const loaded = loadFusionConfig(dir, (message) => notices.push(message));
125
+ assert.equal(loaded?.tool, "claude");
126
+ assert.equal(loaded?.local, true);
127
+ // The migrated copy now exists; the legacy file is left intact as a fallback.
128
+ assert.ok(existsSync(fusionConfigPath(dir)));
129
+ assert.ok(existsSync(legacyFusionConfigPath(dir)));
130
+ assert.equal(notices.length, 1);
131
+ assert.match(notices[0] ?? "", /migrated/);
132
+ });
133
+ test("loadFusionConfig migrates a legacy v1 file and upgrades the version", () => {
134
+ const dir = freshDir();
135
+ writeFileSync(legacyFusionConfigPath(dir), JSON.stringify({ version: "fusionkit.fusion.v1", tool: "codex" }, null, 2) + "\n");
136
+ const loaded = loadFusionConfig(dir);
137
+ assert.equal(loaded?.version, FUSION_CONFIG_VERSION);
138
+ const migrated = JSON.parse(readFileSync(fusionConfigPath(dir), "utf8"));
139
+ assert.equal(migrated.version, FUSION_CONFIG_VERSION);
140
+ });
@@ -9,6 +9,7 @@ import { PassThrough } from "node:stream";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { after, before, test } from "node:test";
11
11
  import { FUSION_REPORT_HEADER, FUSION_RUN_ID_HEADER, runAcpAgent, runFrontDoorAcceptance } from "@fusionkit/model-gateway";
12
+ import { resolveCursorkitCli } from "@fusionkit/ensemble";
12
13
  import { buildAcpRunner, startConfiguredGateway } from "../gateway.js";
13
14
  /**
14
15
  * Comprehensive front-door e2e. Exercises the real chain end to end:
@@ -386,7 +387,9 @@ test("unified acceptance suite passes every reachable front door against the rea
386
387
  assert.equal(statusOf("cursor-acp"), "blocked");
387
388
  assert.ok(backend.judgeCallCount() >= 4, "judge synthesis must hit the model backend per front door");
388
389
  });
389
- const LIVE_CLAUDE = process.env.WARRANT_GATEWAY_LIVE_CLAUDE === "1" ? false : "set WARRANT_GATEWAY_LIVE_CLAUDE=1 with a working claude CLI";
390
+ const LIVE_CLAUDE = (process.env.FUSIONKIT_GATEWAY_LIVE_CLAUDE ?? process.env.WARRANT_GATEWAY_LIVE_CLAUDE) === "1"
391
+ ? false
392
+ : "set FUSIONKIT_GATEWAY_LIVE_CLAUDE=1 with a working claude CLI";
390
393
  test("live: real Claude Code CLI drives the gateway fusion run and receives the synthesized answer", { skip: LIVE_CLAUDE }, async () => {
391
394
  // A dedicated single-model gateway keeps the live run light: each Claude
392
395
  // model call triggers one real unified harness run (worktree + command +
@@ -429,7 +432,9 @@ test("live: real Claude Code CLI drives the gateway fusion run and receives the
429
432
  await liveGateway.close();
430
433
  }
431
434
  });
432
- const LIVE_CODEX = process.env.WARRANT_GATEWAY_LIVE_CODEX === "1" ? false : "set WARRANT_GATEWAY_LIVE_CODEX=1 with a working codex CLI";
435
+ const LIVE_CODEX = (process.env.FUSIONKIT_GATEWAY_LIVE_CODEX ?? process.env.WARRANT_GATEWAY_LIVE_CODEX) === "1"
436
+ ? false
437
+ : "set FUSIONKIT_GATEWAY_LIVE_CODEX=1 with a working codex CLI";
433
438
  test("live: real Codex CLI drives the gateway fusion run and receives the synthesized answer", { skip: LIVE_CODEX }, async () => {
434
439
  // Codex streams `/v1/responses`; the gateway must emit the Responses SSE
435
440
  // sequence ending in response.completed for Codex to accept the answer.
@@ -482,15 +487,14 @@ test("live: real Codex CLI drives the gateway fusion run and receives the synthe
482
487
  rmSync(codexHome, { recursive: true, force: true });
483
488
  }
484
489
  });
485
- // Drives the real cursor-agent CLI in ACP mode through the real Cursorkit
490
+ // Drives the real cursor-agent CLI in ACP mode through the bundled Cursorkit
486
491
  // bridge, whose local model backend is pointed at this gateway. Requires a
487
- // logged-in cursor-agent and a built Cursorkit checkout (WARRANT_CURSORKIT_DIR).
488
- const CURSORKIT_DIR = process.env.WARRANT_CURSORKIT_DIR;
489
- const LIVE_CURSOR = process.env.WARRANT_GATEWAY_LIVE_CURSOR === "1" && CURSORKIT_DIR !== undefined
492
+ // logged-in cursor-agent (Cursorkit is bundled as an npm dependency).
493
+ const LIVE_CURSOR = (process.env.FUSIONKIT_GATEWAY_LIVE_CURSOR ?? process.env.WARRANT_GATEWAY_LIVE_CURSOR) === "1"
490
494
  ? false
491
- : "set WARRANT_GATEWAY_LIVE_CURSOR=1 and WARRANT_CURSORKIT_DIR=<built cursorkit checkout> with a logged-in cursor-agent";
495
+ : "set FUSIONKIT_GATEWAY_LIVE_CURSOR=1 with a logged-in cursor-agent";
492
496
  test("live: real cursor-agent (ACP) drives the Cursorkit bridge into the gateway fusion run", { skip: LIVE_CURSOR }, async () => {
493
- const cursorkitDir = CURSORKIT_DIR;
497
+ const { serveCli } = resolveCursorkitCli();
494
498
  const liveGateway = await startConfiguredGateway({
495
499
  config: { ...config, models: [{ id: "cursor-panel", model: "fusion-cursor" }] },
496
500
  host: "127.0.0.1",
@@ -519,8 +523,7 @@ test("live: real cursor-agent (ACP) drives the Cursorkit bridge into the gateway
519
523
  MODEL_PROVIDER_MODEL: "fusion-panel",
520
524
  MODEL_CONTEXT_TOKEN_LIMIT: "128000"
521
525
  });
522
- const bridge = spawn(process.execPath, ["dist/src/cli.js", "serve"], {
523
- cwd: cursorkitDir,
526
+ const bridge = spawn(process.execPath, [serveCli, "serve"], {
524
527
  env: bridgeEnv,
525
528
  stdio: ["ignore", "pipe", "pipe"]
526
529
  });
@@ -13,23 +13,23 @@ test("claudeEnv points Claude Code at the gateway's Anthropic surface", () => {
13
13
  });
14
14
  test("claudeEnv falls back to a placeholder auth token", () => {
15
15
  const env = claudeEnv("http://127.0.0.1:9000");
16
- assert.equal(env.ANTHROPIC_AUTH_TOKEN, "warrant-local");
16
+ assert.equal(env.ANTHROPIC_AUTH_TOKEN, "fusionkit-local");
17
17
  });
18
18
  test("codexConfigToml declares a Responses provider at the gateway", () => {
19
19
  const toml = codexConfigToml("http://127.0.0.1:9000", "local-model");
20
20
  assert.ok(toml.includes('model = "local-model"'));
21
- assert.ok(toml.includes("[model_providers.warrant-local]"));
21
+ assert.ok(toml.includes("[model_providers.fusionkit-local]"));
22
22
  assert.ok(toml.includes('base_url = "http://127.0.0.1:9000/v1"'));
23
23
  assert.ok(toml.includes('wire_api = "responses"'));
24
24
  assert.ok(toml.includes("requires_openai_auth = false"));
25
25
  });
26
26
  test("opencodeConfig registers an OpenAI-compatible provider", () => {
27
27
  const config = opencodeConfig("http://127.0.0.1:9000", "local-model");
28
- const provider = config.provider["warrant-local"];
28
+ const provider = config.provider["fusionkit-local"];
29
29
  assert.equal(provider?.npm, "@ai-sdk/openai-compatible");
30
30
  assert.equal(provider?.options.baseURL, "http://127.0.0.1:9000/v1");
31
31
  assert.ok("local-model" in (provider?.models ?? {}));
32
- assert.equal(opencodeModelArg("local-model"), "warrant-local/local-model");
32
+ assert.equal(opencodeModelArg("local-model"), "fusionkit-local/local-model");
33
33
  });
34
34
  test("cursorInstructions surfaces the public URL and plan-mode caveat", () => {
35
35
  const text = cursorInstructions("https://abc.example", "local-model");
@@ -0,0 +1,2 @@
1
+ import type { ToolRegistry } from "@fusionkit/tools";
2
+ export declare const toolRegistry: ToolRegistry;
package/dist/tools.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The fusionkit tool registry: the single place that knows every tool package.
3
+ * Importing this module also wires the ensemble harness gateway to resolve
4
+ * tool-backed adapters (codex / claude-code / cursor) from the registry, so
5
+ * `@fusionkit/ensemble` itself stays free of any per-tool dependency.
6
+ *
7
+ * Adding a new tool is one new `@fusionkit/tool-*` package plus one entry here.
8
+ */
9
+ import { setToolHarnessProvider } from "@fusionkit/ensemble";
10
+ import { createToolRegistry } from "@fusionkit/tools";
11
+ import { claudeTool } from "@fusionkit/tool-claude";
12
+ import { codexTool } from "@fusionkit/tool-codex";
13
+ import { cursorTool } from "@fusionkit/tool-cursor";
14
+ import { opencodeTool } from "@fusionkit/tool-opencode";
15
+ export const toolRegistry = createToolRegistry([
16
+ codexTool,
17
+ claudeTool,
18
+ cursorTool,
19
+ opencodeTool
20
+ ]);
21
+ setToolHarnessProvider({
22
+ adapter: (kind, options) => toolRegistry.harnessForKind(kind, options),
23
+ sideEffects: (kind) => toolRegistry.sideEffectsForKind(kind),
24
+ responseShape: (kind) => toolRegistry.responseShapeForKind(kind)
25
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusionkit/cli",
3
3
  "private": false,
4
- "version": "0.1.5",
4
+ "version": "0.1.7",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/handoffkit.git",
@@ -34,14 +34,19 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "commander": "14.0.3",
37
- "@fusionkit/ensemble": "0.1.5",
38
- "@fusionkit/model-gateway": "0.1.5",
39
- "@fusionkit/handoff": "0.1.5",
40
- "@fusionkit/protocol": "0.1.5",
41
- "@fusionkit/runner": "0.1.5",
42
- "@fusionkit/plane": "0.1.5",
43
- "@fusionkit/sdk": "0.1.5",
44
- "@fusionkit/workspace": "0.1.5"
37
+ "@fusionkit/ensemble": "0.1.7",
38
+ "@fusionkit/plane": "0.1.7",
39
+ "@fusionkit/model-gateway": "0.1.7",
40
+ "@fusionkit/runner": "0.1.7",
41
+ "@fusionkit/protocol": "0.1.7",
42
+ "@fusionkit/handoff": "0.1.7",
43
+ "@fusionkit/sdk": "0.1.7",
44
+ "@fusionkit/tool-claude": "0.1.7",
45
+ "@fusionkit/tool-cursor": "0.1.7",
46
+ "@fusionkit/tool-codex": "0.1.7",
47
+ "@fusionkit/tools": "0.1.7",
48
+ "@fusionkit/workspace": "0.1.7",
49
+ "@fusionkit/tool-opencode": "0.1.7"
45
50
  },
46
51
  "optionalDependencies": {
47
52
  "portless": "0.14.0"
@@ -1 +1 @@
1
- vxqImMqlOwssVTua5Facf
1
+ BrrtQvnEIgv-OVkaeanKI
@@ -45,6 +45,13 @@
45
45
  "static/chunks/main-app-2a6b1f94de31f96f.js",
46
46
  "static/chunks/app/api/replay/route-95e822afbebe548b.js"
47
47
  ],
48
+ "/api/sessions/[traceId]/route": [
49
+ "static/chunks/webpack-4501ec292abda191.js",
50
+ "static/chunks/4bd1b696-409494caf8c83275.js",
51
+ "static/chunks/255-69a4a78fac9becef.js",
52
+ "static/chunks/main-app-2a6b1f94de31f96f.js",
53
+ "static/chunks/app/api/sessions/[traceId]/route-95e822afbebe548b.js"
54
+ ],
48
55
  "/api/sessions/route": [
49
56
  "static/chunks/webpack-4501ec292abda191.js",
50
57
  "static/chunks/4bd1b696-409494caf8c83275.js",
@@ -59,12 +66,13 @@
59
66
  "static/chunks/main-app-2a6b1f94de31f96f.js",
60
67
  "static/chunks/app/api/stream/route-95e822afbebe548b.js"
61
68
  ],
62
- "/api/sessions/[traceId]/route": [
69
+ "/environments/page": [
63
70
  "static/chunks/webpack-4501ec292abda191.js",
64
71
  "static/chunks/4bd1b696-409494caf8c83275.js",
65
72
  "static/chunks/255-69a4a78fac9becef.js",
66
73
  "static/chunks/main-app-2a6b1f94de31f96f.js",
67
- "static/chunks/app/api/sessions/[traceId]/route-95e822afbebe548b.js"
74
+ "static/chunks/239-1c69ce437d02745f.js",
75
+ "static/chunks/app/environments/page-75403c3640fdf9de.js"
68
76
  ],
69
77
  "/models/page": [
70
78
  "static/chunks/webpack-4501ec292abda191.js",
@@ -75,14 +83,6 @@
75
83
  "static/chunks/873-9351d1edaa9d58ef.js",
76
84
  "static/chunks/app/models/page-d9b7d19485e9a640.js"
77
85
  ],
78
- "/environments/page": [
79
- "static/chunks/webpack-4501ec292abda191.js",
80
- "static/chunks/4bd1b696-409494caf8c83275.js",
81
- "static/chunks/255-69a4a78fac9becef.js",
82
- "static/chunks/main-app-2a6b1f94de31f96f.js",
83
- "static/chunks/239-1c69ce437d02745f.js",
84
- "static/chunks/app/environments/page-75403c3640fdf9de.js"
85
- ],
86
86
  "/page": [
87
87
  "static/chunks/webpack-4501ec292abda191.js",
88
88
  "static/chunks/4bd1b696-409494caf8c83275.js",
@@ -4,11 +4,11 @@
4
4
  "/api/models/route": "/api/models",
5
5
  "/api/ingest/route": "/api/ingest",
6
6
  "/api/replay/route": "/api/replay",
7
+ "/api/sessions/[traceId]/route": "/api/sessions/[traceId]",
7
8
  "/api/sessions/route": "/api/sessions",
8
9
  "/api/stream/route": "/api/stream",
9
- "/api/sessions/[traceId]/route": "/api/sessions/[traceId]",
10
- "/models/page": "/models",
11
10
  "/environments/page": "/environments",
11
+ "/models/page": "/models",
12
12
  "/page": "/",
13
13
  "/sessions/[traceId]/page": "/sessions/[traceId]"
14
14
  }
@@ -5,8 +5,8 @@
5
5
  "devFiles": [],
6
6
  "ampDevFiles": [],
7
7
  "lowPriorityFiles": [
8
- "static/vxqImMqlOwssVTua5Facf/_buildManifest.js",
9
- "static/vxqImMqlOwssVTua5Facf/_ssgManifest.js"
8
+ "static/BrrtQvnEIgv-OVkaeanKI/_buildManifest.js",
9
+ "static/BrrtQvnEIgv-OVkaeanKI/_ssgManifest.js"
10
10
  ],
11
11
  "rootMainFiles": [
12
12
  "static/chunks/webpack-4501ec292abda191.js",