@fusionkit/cli 0.1.0 → 0.1.1

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 (42) hide show
  1. package/README.md +77 -0
  2. package/dist/cli.d.ts +0 -6
  3. package/dist/cli.js +16 -0
  4. package/dist/commands/doctor.d.ts +2 -0
  5. package/dist/commands/doctor.js +136 -0
  6. package/dist/commands/fusion.js +70 -12
  7. package/dist/fusion-config.d.ts +28 -0
  8. package/dist/fusion-config.js +133 -0
  9. package/dist/fusion-init.d.ts +4 -0
  10. package/dist/fusion-init.js +119 -0
  11. package/dist/fusion-quickstart.d.ts +48 -0
  12. package/dist/fusion-quickstart.js +340 -131
  13. package/dist/gateway.d.ts +2 -0
  14. package/dist/gateway.js +16 -4
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/quiet-warnings.d.ts +1 -0
  18. package/dist/quiet-warnings.js +24 -0
  19. package/dist/shared/preflight.d.ts +1 -0
  20. package/dist/shared/preflight.js +1 -1
  21. package/dist/shared/proc.d.ts +36 -5
  22. package/dist/shared/proc.js +133 -25
  23. package/dist/test/fusion-config.test.d.ts +1 -0
  24. package/dist/test/fusion-config.test.js +80 -0
  25. package/dist/test/proc.test.js +23 -1
  26. package/dist/test/ui.test.d.ts +1 -0
  27. package/dist/test/ui.test.js +24 -0
  28. package/dist/ui/boot.d.ts +23 -0
  29. package/dist/ui/boot.js +56 -0
  30. package/dist/ui/index.d.ts +8 -0
  31. package/dist/ui/index.js +6 -0
  32. package/dist/ui/prompt.d.ts +30 -0
  33. package/dist/ui/prompt.js +178 -0
  34. package/dist/ui/runtime.d.ts +14 -0
  35. package/dist/ui/runtime.js +33 -0
  36. package/dist/ui/spinner.d.ts +31 -0
  37. package/dist/ui/spinner.js +102 -0
  38. package/dist/ui/steps.d.ts +38 -0
  39. package/dist/ui/steps.js +149 -0
  40. package/dist/ui/theme.d.ts +35 -0
  41. package/dist/ui/theme.js +52 -0
  42. package/package.json +9 -9
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # fusionkit
2
+
3
+ Real model fusion behind your coding agent. `fusionkit` spins up a panel of
4
+ models, has each produce a real candidate, and lets a judge synthesize the
5
+ answer your coding agent (Codex, Claude Code, or Cursor) actually runs — all from
6
+ one command.
7
+
8
+ ```bash
9
+ npm install -g @fusionkit/cli
10
+ cd your-project # a git repo
11
+ fusionkit doctor # check prerequisites
12
+ fusionkit codex # launch Codex backed by the fusion panel
13
+ ```
14
+
15
+ ## Prerequisites
16
+
17
+ `fusionkit` orchestrates other tools, so a few things must be available:
18
+
19
+ - **[uv](https://docs.astral.sh/uv/getting-started/installation/)** — provides
20
+ `uvx`, used to run the Python synthesizer (`fusionkit` on PyPI). No manual
21
+ Python install needed.
22
+ - **A coding agent on your PATH** — one of:
23
+ [`codex`](https://github.com/openai/codex),
24
+ [`claude`](https://docs.anthropic.com/en/docs/claude-code/overview), or
25
+ [`cursor-agent`](https://cursor.com/cli).
26
+ - **Provider API keys** for the default cloud panel: `OPENAI_API_KEY` and
27
+ `ANTHROPIC_API_KEY` (exported, or in a project `.env` — fusionkit loads it
28
+ automatically). Not needed for the local MLX panel (`--local`, Apple Silicon).
29
+ - **A git repository** — the panel fuses over the code in your current repo.
30
+
31
+ Run `fusionkit doctor` any time to see exactly what is and isn't ready.
32
+
33
+ > Two packages share the name "fusionkit": this npm CLI (`@fusionkit/cli`, the
34
+ > `fusionkit` command) and the Python distribution (`fusionkit` on PyPI) that
35
+ > provides the synthesizer. The CLI fetches the pinned PyPI build via `uvx`
36
+ > automatically; `fusionkit --version` prints both versions.
37
+
38
+ ## Cost
39
+
40
+ The default panel runs **multiple frontier cloud models plus a judge** on every
41
+ prompt, so usage adds up. fusionkit asks for confirmation before starting a
42
+ cloud panel (skip with `--yes`). Use `--local` for the on-device MLX panel, or
43
+ `--model` to pick cheaper models.
44
+
45
+ ## Per-repo config
46
+
47
+ Tired of long flag lines? Scaffold a committed `fusionkit.json`:
48
+
49
+ ```bash
50
+ fusionkit fusion init
51
+ ```
52
+
53
+ It records the panel, judge, default tool, and run defaults so the whole team
54
+ can just run `fusionkit codex`. Only env-var *names* for keys are stored, never
55
+ secrets. Explicit CLI flags always override the file. Inspect the effective
56
+ config and a dry-run preview with `fusionkit status`.
57
+
58
+ ## Commands
59
+
60
+ - `fusionkit codex | claude | cursor` — launch that agent backed by the panel.
61
+ - `fusionkit serve` — just run the gateway and print setup snippets for any tool.
62
+ - `fusionkit fusion [tool]` — the generic launcher (interactive picker on a TTY).
63
+ - `fusionkit fusion init` — scaffold `fusionkit.json` for this repo.
64
+ - `fusionkit doctor` — check prerequisites with fix hints.
65
+ - `fusionkit status` — show the effective config and what a run will do.
66
+
67
+ Useful flags: `--local`, `--observe`, `--model ID=PROVIDER:MODEL`,
68
+ `--judge-model`, `--repo <dir>`, `--yes`. fusionkit's own flags must precede the
69
+ tool name; everything after the tool is forwarded to it.
70
+
71
+ ## Notes
72
+
73
+ - `--observe` boots a local dashboard that streams live trace events. It is a
74
+ separate app and is not bundled in the npm package; fusionkit prints how to
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.
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,2 @@
1
1
  import { Command } from "commander";
2
- /**
3
- * Build the `fusionkit` command tree. The global `--dir` option must precede the
4
- * subcommand (`enablePositionalOptions` keeps the launcher commands' passthrough
5
- * unambiguous). Each `register*` helper attaches its command(s) and reads the
6
- * global home directory via `program.opts().dir`.
7
- */
8
2
  export declare function buildProgram(): Command;
package/dist/cli.js CHANGED
@@ -1,4 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
1
3
  import { Command } from "commander";
4
+ import { FUSIONKIT_PYPI_VERSION } from "./fusion-quickstart.js";
5
+ import { registerDoctor } from "./commands/doctor.js";
2
6
  import { registerEnsemble } from "./commands/ensemble.js";
3
7
  import { registerFusion } from "./commands/fusion.js";
4
8
  import { registerInit } from "./commands/init.js";
@@ -14,11 +18,22 @@ import { registerSecrets } from "./commands/secrets.js";
14
18
  * unambiguous). Each `register*` helper attaches its command(s) and reads the
15
19
  * global home directory via `program.opts().dir`.
16
20
  */
21
+ function cliVersion() {
22
+ // dist/cli.js -> ../package.json is the published package manifest.
23
+ try {
24
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
25
+ return pkg.version ?? "0.0.0";
26
+ }
27
+ catch {
28
+ return "0.0.0";
29
+ }
30
+ }
17
31
  export function buildProgram() {
18
32
  const program = new Command();
19
33
  program
20
34
  .name("fusionkit")
21
35
  .description("real model fusion behind your coding agent (codex, claude, cursor)")
36
+ .version(`@fusionkit/cli ${cliVersion()} (synthesizer: fusionkit@${FUSIONKIT_PYPI_VERSION} from PyPI)`, "-v, --version", "print the CLI (npm) and pinned synthesizer (PyPI) versions")
22
37
  .option("-d, --dir <dir>", "fusionkit home (default: ./.fusionkit)")
23
38
  .enablePositionalOptions();
24
39
  registerInit(program);
@@ -30,5 +45,6 @@ export function buildProgram() {
30
45
  registerEnsemble(program);
31
46
  registerLocal(program);
32
47
  registerFusion(program);
48
+ registerDoctor(program);
33
49
  return program;
34
50
  }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerDoctor(program: Command): void;
@@ -0,0 +1,136 @@
1
+ import { join } from "node:path";
2
+ import { DEFAULT_CLOUD_PANEL, DEFAULT_TRIO, defaultKeyEnv, gitToplevel, loadEnvFileInto } from "../fusion-quickstart.js";
3
+ import { loadFusionConfig, fusionConfigPath, FusionConfigError } from "../fusion-config.js";
4
+ import { hasBinary, INSTALL_HINTS } from "../shared/preflight.js";
5
+ import { bold, brandHeader, cyan, dim, glyph, gray, green, red, yellow } from "../ui/theme.js";
6
+ function line(check) {
7
+ const mark = check.ok ? green(glyph.tick()) : red(glyph.cross());
8
+ const detail = check.detail !== undefined ? ` ${dim(check.detail)}` : "";
9
+ const hint = !check.ok && check.hint !== undefined ? `\n ${yellow(glyph.arrow())} ${check.hint}` : "";
10
+ return `${mark} ${check.label}${detail}${hint}`;
11
+ }
12
+ function keyPresent(name) {
13
+ const value = process.env[name];
14
+ return value !== undefined && value.length > 0;
15
+ }
16
+ /** `fusionkit doctor` — a proactive environment checklist with fix hints. */
17
+ function runDoctor() {
18
+ // Match runtime: a project .env makes provider keys available without export.
19
+ loadEnvFileInto(join(process.cwd(), ".env"), process.env);
20
+ console.log(`\n${brandHeader("environment check")}\n`);
21
+ const runner = hasBinary("uvx") || hasBinary("uv");
22
+ const checks = [];
23
+ checks.push({
24
+ label: "uv / uvx (Python runner for the synthesizer)",
25
+ ok: runner,
26
+ ...(runner ? {} : { hint: INSTALL_HINTS.uvx })
27
+ });
28
+ checks.push({ label: "git (repo detection)", ok: hasBinary("git"), hint: "install git" });
29
+ const repoRoot = gitToplevel(process.cwd());
30
+ checks.push({
31
+ label: "inside a git repository",
32
+ ok: repoRoot !== undefined,
33
+ ...(repoRoot !== undefined ? { detail: repoRoot } : { hint: "cd into your project, or run `git init`" })
34
+ });
35
+ console.log(bold("prerequisites"));
36
+ for (const check of checks)
37
+ console.log(` ${line(check)}`);
38
+ console.log("");
39
+ console.log(bold("coding agents (install the one you use)"));
40
+ for (const [bin, tool] of [
41
+ ["codex", "codex"],
42
+ ["claude", "claude"],
43
+ ["cursor-agent", "cursor"]
44
+ ]) {
45
+ const ok = hasBinary(bin);
46
+ console.log(` ${line({ label: `${tool} (${bin})`, ok, ...(ok ? {} : { hint: INSTALL_HINTS[bin] ?? `install ${bin}` }) })}`);
47
+ }
48
+ console.log("");
49
+ console.log(bold("provider keys (needed by the cloud panel)"));
50
+ for (const name of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"]) {
51
+ const ok = keyPresent(name);
52
+ console.log(` ${line({ label: name, ok, detail: ok ? "set" : "not set", ...(ok ? {} : { hint: `export ${name}=... (or add it to .env)` }) })}`);
53
+ }
54
+ // Config status, if any.
55
+ if (repoRoot !== undefined) {
56
+ console.log("");
57
+ console.log(bold("repo config"));
58
+ try {
59
+ const config = loadFusionConfig(repoRoot);
60
+ if (config === undefined) {
61
+ console.log(` ${gray(glyph.bullet())} no ${cyan("fusionkit.json")} yet — run ${bold("fusionkit fusion init")}`);
62
+ }
63
+ else {
64
+ console.log(` ${green(glyph.tick())} ${cyan(fusionConfigPath(repoRoot))}`);
65
+ console.log(` ${dim(`tool: ${config.tool ?? "(unset)"} panel: ${(config.panel ?? []).map((s) => s.id).join(", ") || "(unset)"}`)}`);
66
+ }
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof FusionConfigError ? error.message : String(error);
70
+ console.log(` ${red(glyph.cross())} ${message}`);
71
+ }
72
+ }
73
+ console.log("");
74
+ if (!runner) {
75
+ console.log(red("fusionkit needs uv/uvx to run the synthesizer. Install it, then re-run `fusionkit doctor`."));
76
+ return 1;
77
+ }
78
+ console.log(green("ready. Try: ") + bold("fusionkit codex"));
79
+ return 0;
80
+ }
81
+ function panelLabel(spec) {
82
+ const provider = spec.provider ?? "mlx";
83
+ const key = spec.keyEnv ?? defaultKeyEnv(provider);
84
+ const keyNote = key !== undefined ? ` ${gray(`[${key}]`)}` : "";
85
+ return `${spec.id} = ${provider}:${spec.model}${keyNote}`;
86
+ }
87
+ /** `fusionkit status` — show the effective config and a dry-run preview. */
88
+ function runStatus() {
89
+ const repoRoot = gitToplevel(process.cwd());
90
+ console.log(`\n${brandHeader("status")}\n`);
91
+ if (repoRoot === undefined) {
92
+ console.log(gray("not inside a git repository; run from your project."));
93
+ return 0;
94
+ }
95
+ let config;
96
+ try {
97
+ config = loadFusionConfig(repoRoot);
98
+ }
99
+ catch (error) {
100
+ console.log(`${red("config error:")} ${error instanceof Error ? error.message : String(error)}`);
101
+ return 1;
102
+ }
103
+ const local = config?.local === true;
104
+ const panel = config?.panel ?? (local ? [...DEFAULT_TRIO] : [...DEFAULT_CLOUD_PANEL]);
105
+ const tool = config?.tool ?? "codex";
106
+ const judge = config?.judgeModel ?? panel[0]?.model ?? "(first panel model)";
107
+ const source = config !== undefined ? cyan(fusionConfigPath(repoRoot)) : dim("(built-in defaults; run `fusionkit fusion init`)");
108
+ console.log(`${dim("config:")} ${source}`);
109
+ console.log(`${dim("repo:")} ${repoRoot}`);
110
+ console.log(`${dim("tool:")} ${bold(tool)}`);
111
+ console.log(`${dim("judge:")} ${judge}`);
112
+ console.log(`${dim("observe:")} ${config?.observe === true ? "on" : "off"}`);
113
+ console.log(bold("\npanel"));
114
+ for (const spec of panel)
115
+ console.log(` ${glyph.bullet()} ${panelLabel(spec)}`);
116
+ const spawnsCloud = panel.some((spec) => (spec.provider ?? "mlx") !== "mlx");
117
+ console.log("");
118
+ console.log(dim(`a run will: spawn ${panel.length} model server(s), a synthesizer, and the gateway, then launch ${tool}.`));
119
+ if (spawnsCloud)
120
+ console.log(yellow(`${glyph.warn()} cloud panel: each prompt fans out across the panel + judge (provider usage applies).`));
121
+ return 0;
122
+ }
123
+ export function registerDoctor(program) {
124
+ program
125
+ .command("doctor")
126
+ .description("check that prerequisites (uv, agents, keys, git) are ready")
127
+ .action(() => {
128
+ process.exit(runDoctor());
129
+ });
130
+ program
131
+ .command("status")
132
+ .description("show the effective fusion config and a dry-run preview")
133
+ .action(() => {
134
+ process.exit(runStatus());
135
+ });
136
+ }
@@ -1,5 +1,8 @@
1
1
  import { resolve } from "node:path";
2
- import { FUSION_TOOLS, pickTool, runFusion } from "../fusion-quickstart.js";
2
+ import { FUSION_TOOLS, gitToplevel, pickTool, runFusion } from "../fusion-quickstart.js";
3
+ import { loadFusionConfig } from "../fusion-config.js";
4
+ import { runFusionInit } from "../fusion-init.js";
5
+ import { fail } from "../shared/errors.js";
3
6
  import { collect, parseFusionTool, parseIdValue, parsePanelModelSpec, parsePort } from "../shared/options.js";
4
7
  /** Attach the panel/gateway flags shared by `fusion` and the per-tool launchers. */
5
8
  function applyFusionOptions(command) {
@@ -14,13 +17,16 @@ function applyFusionOptions(command) {
14
17
  .option("--repo <dir>", "coding workspace the panel fuses over")
15
18
  .option("--cursor-kit-dir <dir>", "built Cursorkit checkout for the cursor tool")
16
19
  .option("--local", "use the local MLX panel trio instead of the default cloud panel")
20
+ .option("--no-local", "override a fusionkit.json default of local=true")
17
21
  .option("--observe", "boot the local scope dashboard and stream live trace events")
22
+ .option("--no-observe", "override a fusionkit.json default of observe=true")
23
+ .option("--yes", "skip the interactive cloud-panel cost confirmation")
18
24
  .option("--auth-token <token>", "require a bearer token on the gateway")
19
25
  .option("--port <n>", "gateway port (default: ephemeral)")
20
26
  .allowUnknownOption()
21
27
  .passThroughOptions();
22
28
  }
23
- /** Build the `RunFusionOptions` shared by every entrypoint from parsed flags. */
29
+ /** Build the flag-only `RunFusionOptions` (no config/defaults applied yet). */
24
30
  function resolveOptions(opts) {
25
31
  const options = {};
26
32
  const keyEnvs = {};
@@ -38,10 +44,14 @@ function resolveOptions(opts) {
38
44
  options.repo = resolve(opts.repo);
39
45
  if (opts.cursorKitDir !== undefined)
40
46
  options.cursorKitDir = resolve(opts.cursorKitDir);
41
- if (opts.local === true)
42
- options.local = true;
43
- if (opts.observe === true)
44
- options.observe = true;
47
+ // local/observe are tri-state: only set when the user passed --local/--no-local
48
+ // (or --observe/--no-observe), so an unset flag can fall through to the config.
49
+ if (opts.local !== undefined)
50
+ options.local = opts.local;
51
+ if (opts.observe !== undefined)
52
+ options.observe = opts.observe;
53
+ if (opts.yes === true)
54
+ options.yes = true;
45
55
  if (opts.authToken !== undefined)
46
56
  options.authToken = opts.authToken;
47
57
  if (opts.port !== undefined)
@@ -71,16 +81,65 @@ function resolveOptions(opts) {
71
81
  }
72
82
  return options;
73
83
  }
84
+ /** Fill any option the user did not set explicitly from `fusionkit.json`. */
85
+ function mergeConfig(options, config) {
86
+ if (options.models === undefined && options.endpoints === undefined && config.panel !== undefined && config.panel.length > 0) {
87
+ options.models = config.panel.map((spec) => ({ ...spec }));
88
+ }
89
+ if (options.judgeModel === undefined && config.judgeModel !== undefined)
90
+ options.judgeModel = config.judgeModel;
91
+ if (options.local === undefined && config.local !== undefined)
92
+ options.local = config.local;
93
+ if (options.observe === undefined && config.observe !== undefined)
94
+ options.observe = config.observe;
95
+ if (options.cursorKitDir === undefined && config.cursorKitDir != null)
96
+ options.cursorKitDir = config.cursorKitDir;
97
+ if (options.port === undefined && config.port != null)
98
+ options.port = config.port;
99
+ }
100
+ /** The repo root used for config lookup: --repo if given, else the cwd's git root. */
101
+ function configRepoRoot(options) {
102
+ return options.repo ?? gitToplevel(process.cwd());
103
+ }
104
+ /**
105
+ * Resolve options + the config-provided default tool. Flags always win over the
106
+ * file; the file wins over built-in defaults.
107
+ */
108
+ function resolveContext(opts) {
109
+ const options = resolveOptions(opts);
110
+ const repoRoot = configRepoRoot(options);
111
+ let config;
112
+ if (repoRoot !== undefined) {
113
+ try {
114
+ config = loadFusionConfig(repoRoot);
115
+ }
116
+ catch (error) {
117
+ fail(error instanceof Error ? error.message : String(error));
118
+ }
119
+ }
120
+ if (config !== undefined)
121
+ mergeConfig(options, config);
122
+ return { options, ...(config?.tool !== undefined ? { configTool: config.tool } : {}) };
123
+ }
74
124
  export function registerFusion(program) {
75
125
  // Generic `fusion [tool]` — keeps the original surface and interactive pick.
76
126
  applyFusionOptions(program
77
127
  .command("fusion")
78
128
  .description("one command: real model fusion backs a coding agent")
79
- .argument("[tool]", `${FUSION_TOOLS.join(" | ")} (omit on a TTY to pick interactively)`)
129
+ .argument("[tool]", `${FUSION_TOOLS.join(" | ")} | init (omit on a TTY to pick interactively)`)
80
130
  .argument("[args...]", "arguments forwarded to the tool")
81
- .option("--tool <tool>", `coding agent to launch (${FUSION_TOOLS.join(" | ")})`))
82
- .addHelpText("after", "\nfusionkit's own flags must precede the tool name; everything after the tool is forwarded to it.")
131
+ .option("--tool <tool>", `coding agent to launch (${FUSION_TOOLS.join(" | ")})`)
132
+ .option("--force", "overwrite an existing fusionkit.json (with `fusion init`)"))
133
+ .addHelpText("after", "\nfusionkit's own flags must precede the tool name; everything after the tool is forwarded to it." +
134
+ "\nRun `fusionkit fusion init` to scaffold a committed fusionkit.json for this repo.")
83
135
  .action(async (positionalTool, args, opts) => {
136
+ // `fusion init` scaffolds the per-repo config instead of launching a tool.
137
+ if (positionalTool === "init") {
138
+ const repoRoot = configRepoRoot(resolveOptions(opts));
139
+ const code = await runFusionInit({ repoRoot, force: opts.force === true });
140
+ process.exit(code);
141
+ }
142
+ const { options, configTool } = resolveContext(opts);
84
143
  let tool = opts.tool ? parseFusionTool(opts.tool) : undefined;
85
144
  let toolArgs = [...args];
86
145
  if (positionalTool !== undefined) {
@@ -91,8 +150,7 @@ export function registerFusion(program) {
91
150
  toolArgs = [positionalTool, ...toolArgs];
92
151
  }
93
152
  }
94
- const options = resolveOptions(opts);
95
- const resolvedTool = tool ?? (process.stdin.isTTY ? await pickTool() : "codex");
153
+ const resolvedTool = tool ?? configTool ?? (process.stdin.isTTY ? await pickTool() : "codex");
96
154
  const code = await runFusion(resolvedTool, toolArgs, options);
97
155
  process.exit(code);
98
156
  });
@@ -104,7 +162,7 @@ export function registerFusion(program) {
104
162
  .argument("[args...]", `arguments forwarded to ${tool}`))
105
163
  .addHelpText("after", `\nfusionkit's own flags must precede any ${tool} args; everything after is forwarded to ${tool}.`)
106
164
  .action(async (args, opts) => {
107
- const options = resolveOptions(opts);
165
+ const { options } = resolveContext(opts);
108
166
  const code = await runFusion(tool, args, options);
109
167
  process.exit(code);
110
168
  });
@@ -0,0 +1,28 @@
1
+ import type { FusionTool, PanelModelSpec } from "./fusion-quickstart.js";
2
+ export declare const FUSION_CONFIG_FILENAME = "fusionkit.json";
3
+ export declare const FUSION_CONFIG_VERSION = "fusionkit.fusion.v1";
4
+ export type FusionConfig = {
5
+ version: typeof FUSION_CONFIG_VERSION;
6
+ tool?: FusionTool;
7
+ panel?: PanelModelSpec[];
8
+ judgeModel?: string;
9
+ local?: boolean;
10
+ observe?: boolean;
11
+ cursorKitDir?: string | null;
12
+ port?: number | null;
13
+ };
14
+ export declare class FusionConfigError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ export declare function fusionConfigPath(repoRoot: string): string;
18
+ /** Validate a parsed object as a {@link FusionConfig}, throwing on any problem. */
19
+ export declare function parseFusionConfig(raw: unknown, source: string): FusionConfig;
20
+ /**
21
+ * Load `<repoRoot>/fusionkit.json` if present. Returns `undefined` when the file
22
+ * does not exist; throws {@link FusionConfigError} on malformed content.
23
+ */
24
+ export declare function loadFusionConfig(repoRoot: string): FusionConfig | undefined;
25
+ /** Write `fusionkit.json` at the repo root, refusing to clobber unless `force`. */
26
+ export declare function writeFusionConfig(repoRoot: string, config: FusionConfig, options?: {
27
+ force?: boolean;
28
+ }): string;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Per-repo fusion configuration (`fusionkit.json`, committed at the repo root).
3
+ *
4
+ * Captures the panel, judge, default tool, and run defaults so a contributor can
5
+ * just run `fusionkit codex` instead of retyping a long flag line. The file is
6
+ * safe to commit: it stores only the env-var *names* that hold API keys
7
+ * (`keyEnv`), never the secret values.
8
+ *
9
+ * Precedence at run time is: explicit CLI flags > fusionkit.json > built-in
10
+ * defaults. CLI flags always win, so the file is a default layer, not a lock.
11
+ */
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { FUSION_TOOLS } from "./fusion-quickstart.js";
15
+ import { PANEL_PROVIDERS } from "./shared/options.js";
16
+ export const FUSION_CONFIG_FILENAME = "fusionkit.json";
17
+ export const FUSION_CONFIG_VERSION = "fusionkit.fusion.v1";
18
+ export class FusionConfigError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "FusionConfigError";
22
+ }
23
+ }
24
+ export function fusionConfigPath(repoRoot) {
25
+ return join(repoRoot, FUSION_CONFIG_FILENAME);
26
+ }
27
+ function isRecord(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ function validatePanelEntry(entry, index) {
31
+ if (!isRecord(entry)) {
32
+ throw new FusionConfigError(`panel[${index}] must be an object`);
33
+ }
34
+ const { id, model, provider, baseUrl, keyEnv } = entry;
35
+ if (typeof id !== "string" || id.length === 0) {
36
+ throw new FusionConfigError(`panel[${index}].id must be a non-empty string`);
37
+ }
38
+ if (typeof model !== "string" || model.length === 0) {
39
+ throw new FusionConfigError(`panel[${index}].model must be a non-empty string`);
40
+ }
41
+ const spec = { id, model };
42
+ if (provider !== undefined) {
43
+ if (typeof provider !== "string" || !PANEL_PROVIDERS.includes(provider)) {
44
+ throw new FusionConfigError(`panel[${index}].provider must be one of ${PANEL_PROVIDERS.join(", ")}`);
45
+ }
46
+ spec.provider = provider;
47
+ }
48
+ if (baseUrl !== undefined) {
49
+ if (typeof baseUrl !== "string")
50
+ throw new FusionConfigError(`panel[${index}].baseUrl must be a string`);
51
+ spec.baseUrl = baseUrl;
52
+ }
53
+ if (keyEnv !== undefined) {
54
+ if (typeof keyEnv !== "string")
55
+ throw new FusionConfigError(`panel[${index}].keyEnv must be a string`);
56
+ spec.keyEnv = keyEnv;
57
+ }
58
+ return spec;
59
+ }
60
+ /** Validate a parsed object as a {@link FusionConfig}, throwing on any problem. */
61
+ export function parseFusionConfig(raw, source) {
62
+ if (!isRecord(raw))
63
+ throw new FusionConfigError(`${source}: must be a JSON object`);
64
+ if (raw.version !== FUSION_CONFIG_VERSION) {
65
+ throw new FusionConfigError(`${source}: unsupported version ${JSON.stringify(raw.version)} (expected "${FUSION_CONFIG_VERSION}")`);
66
+ }
67
+ const config = { version: FUSION_CONFIG_VERSION };
68
+ if (raw.tool !== undefined) {
69
+ if (typeof raw.tool !== "string" || !FUSION_TOOLS.includes(raw.tool)) {
70
+ throw new FusionConfigError(`${source}: tool must be one of ${FUSION_TOOLS.join(", ")}`);
71
+ }
72
+ config.tool = raw.tool;
73
+ }
74
+ if (raw.panel !== undefined) {
75
+ if (!Array.isArray(raw.panel))
76
+ throw new FusionConfigError(`${source}: panel must be an array`);
77
+ config.panel = raw.panel.map((entry, index) => validatePanelEntry(entry, index));
78
+ }
79
+ if (raw.judgeModel !== undefined) {
80
+ if (typeof raw.judgeModel !== "string")
81
+ throw new FusionConfigError(`${source}: judgeModel must be a string`);
82
+ config.judgeModel = raw.judgeModel;
83
+ }
84
+ if (raw.local !== undefined) {
85
+ if (typeof raw.local !== "boolean")
86
+ throw new FusionConfigError(`${source}: local must be a boolean`);
87
+ config.local = raw.local;
88
+ }
89
+ if (raw.observe !== undefined) {
90
+ if (typeof raw.observe !== "boolean")
91
+ throw new FusionConfigError(`${source}: observe must be a boolean`);
92
+ config.observe = raw.observe;
93
+ }
94
+ if (raw.cursorKitDir !== undefined && raw.cursorKitDir !== null) {
95
+ if (typeof raw.cursorKitDir !== "string") {
96
+ throw new FusionConfigError(`${source}: cursorKitDir must be a string or null`);
97
+ }
98
+ config.cursorKitDir = raw.cursorKitDir;
99
+ }
100
+ if (raw.port !== undefined && raw.port !== null) {
101
+ if (typeof raw.port !== "number" || !Number.isInteger(raw.port) || raw.port < 0) {
102
+ throw new FusionConfigError(`${source}: port must be a non-negative integer or null`);
103
+ }
104
+ config.port = raw.port;
105
+ }
106
+ return config;
107
+ }
108
+ /**
109
+ * Load `<repoRoot>/fusionkit.json` if present. Returns `undefined` when the file
110
+ * does not exist; throws {@link FusionConfigError} on malformed content.
111
+ */
112
+ export function loadFusionConfig(repoRoot) {
113
+ const path = fusionConfigPath(repoRoot);
114
+ if (!existsSync(path))
115
+ return undefined;
116
+ let raw;
117
+ try {
118
+ raw = JSON.parse(readFileSync(path, "utf8"));
119
+ }
120
+ catch (error) {
121
+ throw new FusionConfigError(`${path}: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
122
+ }
123
+ return parseFusionConfig(raw, path);
124
+ }
125
+ /** Write `fusionkit.json` at the repo root, refusing to clobber unless `force`. */
126
+ export function writeFusionConfig(repoRoot, config, options = {}) {
127
+ const path = fusionConfigPath(repoRoot);
128
+ if (existsSync(path) && options.force !== true) {
129
+ throw new FusionConfigError(`${path} already exists (pass --force to overwrite)`);
130
+ }
131
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
132
+ return path;
133
+ }
@@ -0,0 +1,4 @@
1
+ export declare function runFusionInit(input: {
2
+ repoRoot?: string;
3
+ force?: boolean;
4
+ }): Promise<number>;