modelfunction-cli 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mf.js CHANGED
@@ -3,18 +3,30 @@ import { readFile } from "node:fs/promises";
3
3
  import { basename, dirname, extname, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { loadSuite, toModelTools } from "./lib/suite.js";
6
- import { scoreModel, pct } from "./lib/score.js";
6
+ import { scoreModel, pickRepresentative, pct } from "./lib/score.js";
7
7
  import { formatTable, formatCaseLines, formatMarkdown } from "./lib/report.js";
8
8
  import { scanProject, buildSuiteDraft, formatScanSummary, addTools, redactReport } from "./lib/scan.js";
9
9
  import { probeStdioServer } from "./lib/mcp-probe.js";
10
10
  import { loadAdapterTools } from "./lib/adapter-import.js";
11
11
  import { addCases, computeCoverage, formatCoverage } from "./lib/cases.js";
12
12
  import { lintSuite, formatLint } from "./lib/lint.js";
13
- import { suiteFingerprint, hashText } from "./lib/fingerprint.js";
13
+ import { suiteFingerprint, hashText, hashOf } from "./lib/fingerprint.js";
14
14
  import { runExecutionChecks } from "./lib/eval-exec.js";
15
15
  import { checkSandbox, formatSandboxReport } from "./lib/sandbox-check.js";
16
16
  import { compareScorecards, formatCompare } from "./lib/compare.js";
17
17
  import { analyzeAblation, formatAblation } from "./lib/ablate.js";
18
+ import { clusterTools, formatClusters } from "./lib/cluster.js";
19
+ import {
20
+ normalizeProfiles,
21
+ selectProfiles,
22
+ selectProfileCases,
23
+ profileWarnings,
24
+ analyzeMount,
25
+ modelTotals,
26
+ profileCaseCoverage,
27
+ formatProfileReport,
28
+ formatProfileMarkdown
29
+ } from "./lib/profile.js";
18
30
  import { runExec } from "./lib/exec.js";
19
31
  import { writeFileAtomic } from "./lib/fsutil.js";
20
32
  import { createMockAdapter } from "./lib/adapters/mock.js";
@@ -40,7 +52,8 @@ Usage:
40
52
  mf coverage [--suite <file>] [--json]
41
53
  mf lint [--suite <file>] [--json]
42
54
  mf compare <before.json> <after.json> [--json]
43
- mf ablate --tool <name> [--suite <file>] [--models <ids>] [--json]
55
+ mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--json]
56
+ mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组
44
57
  mf exec --entry <file> (--input <json> | --input-json <json>) [--runtime node|python] [--python <cmd>] [--export run] [--allow-read <paths>] [--allow-write <paths>] [--allow-net <hosts>] [--timeout <ms>] [--warmup <n>] [--json]
45
58
  mf sandbox check [--json] # probe available isolation engines on this machine
46
59
  mf mcp # start stdio MCP server for coding agents
@@ -50,6 +63,8 @@ Eval options:
50
63
  --models <ids> comma-separated ids from models.json (default: models with API keys set)
51
64
  --models-file <path> model registry (default: built-in registry)
52
65
  --case <ids> run only these case ids (comma-separated, repeatable)
66
+ --profile <names> score each mounted group separately ("all" or names from suite.profiles)
67
+ --repeat <n> run each case n times and score the majority; reports unstable cases (default 1)
53
68
  --show-cases print per-case results
54
69
  --json print JSON instead of the table
55
70
  --out <file> write scorecard (.md => markdown, otherwise JSON)
@@ -81,23 +96,27 @@ Examples:
81
96
  `;
82
97
 
83
98
  export async function runEval(options, io = console) {
99
+ if (options.profiles && options.execute) {
100
+ throw new Error("--profile 暂不支持与 --execute 组合");
101
+ }
84
102
  const suite = await loadSuite(options.suite);
85
103
  const cases = selectCases(suite.cases, options.cases);
86
104
  const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
87
105
  const tools = toModelTools(suite.tools);
88
106
 
89
- const summaries = [];
107
+ const runs = [];
90
108
  for (const adapter of adapters) {
91
- const results = await evaluate(adapter, tools, cases);
92
- summaries.push(
93
- scoreModel({
94
- model: adapter.id,
95
- cases,
96
- results,
97
- pricing: registry[adapter.id]?.pricing ?? null
98
- })
99
- );
109
+ const evaluated = await evaluate(adapter, tools, cases, options.repeat ?? 1);
110
+ const summary = scoreModel({
111
+ model: adapter.id,
112
+ cases,
113
+ results: evaluated.results,
114
+ pricing: registry[adapter.id]?.pricing ?? null
115
+ });
116
+ if (evaluated.stability) summary.stability = evaluated.stability;
117
+ runs.push({ adapter, results: evaluated.results, summary });
100
118
  }
119
+ const summaries = runs.map((run) => run.summary);
101
120
 
102
121
  let executionInfo = null;
103
122
  if (options.execute) {
@@ -124,18 +143,30 @@ export async function runEval(options, io = console) {
124
143
  };
125
144
  }
126
145
 
146
+ const profileReport = options.profiles
147
+ ? await evaluateProfiles({
148
+ suite,
149
+ cases,
150
+ runs,
151
+ registry,
152
+ selection: options.profiles,
153
+ repeat: options.repeat ?? 1
154
+ })
155
+ : null;
156
+
127
157
  if (options.out) {
128
158
  const target = resolve(options.out);
129
159
  const markdown = extname(target).toLowerCase() === ".md";
130
160
  const payload = markdown
131
- ? formatMarkdown(suite, cases, summaries)
132
- : JSON.stringify(serialize(suite, cases, summaries, executionInfo), null, 2);
161
+ ? formatMarkdown(suite, cases, summaries) +
162
+ (profileReport ? `\n\n${formatProfileMarkdown(profileReport)}` : "")
163
+ : JSON.stringify(serialize(suite, cases, summaries, executionInfo, profileReport), null, 2);
133
164
  await writeFileAtomic(target, payload);
134
165
  io.log(`wrote ${target}`);
135
166
  }
136
167
 
137
168
  if (options.json) {
138
- io.log(JSON.stringify(serialize(suite, cases, summaries, executionInfo), null, 2));
169
+ io.log(JSON.stringify(serialize(suite, cases, summaries, executionInfo, profileReport), null, 2));
139
170
  } else {
140
171
  io.log(`suite: ${suite.name} — ${cases.length} cases, ${suite.tools.length} tools`);
141
172
  io.log(`file: ${suite.path}`);
@@ -147,6 +178,14 @@ export async function runEval(options, io = console) {
147
178
  io.log("");
148
179
  }
149
180
  io.log(formatTable([summary]));
181
+ if (summary.stability) {
182
+ const unstable = summary.stability.unstable;
183
+ const list = unstable.slice(0, 8).map((item) => item.id).join(", ");
184
+ io.log(
185
+ `stability: ${summary.stability.runs} 次/用例,${unstable.length} 条结果不稳定` +
186
+ (unstable.length > 0 ? `(${list}${unstable.length > 8 ? "…" : ""})` : "")
187
+ );
188
+ }
150
189
  if (summary.unreviewedTotal > 0) {
151
190
  io.log(`note: ${summary.unreviewedTotal} unreviewed case(s) excluded from select/args/pass`);
152
191
  }
@@ -170,10 +209,18 @@ export async function runEval(options, io = console) {
170
209
  io.log("combined");
171
210
  io.log(formatTable(summaries));
172
211
  }
212
+ if (profileReport) {
213
+ io.log("");
214
+ io.log(formatProfileReport(profileReport, { showCases: options.showCases }));
215
+ }
173
216
  if (summaries.some((summary) => summary.rows.some((row) => row.usage?.estimated))) {
174
217
  io.log("");
175
218
  io.log("note: tokens marked with ~ are estimated (mock adapter); pricing in models.json is indicative.");
176
219
  }
220
+ if (summaries.some((summary) => summary.model === "mock")) {
221
+ io.log("");
222
+ io.log("note: mock 是规则路由器,只用于验证流程;它在你自己的工具上得低分不代表真实模型表现,请用 --models <id> 测真实模型。");
223
+ }
177
224
  }
178
225
 
179
226
  let regressions = [];
@@ -210,6 +257,17 @@ export async function runEval(options, io = console) {
210
257
  );
211
258
  return 2;
212
259
  }
260
+ if (profileReport && options.failUnder !== null) {
261
+ const below = profileReport.models.flatMap((model) =>
262
+ model.profiles
263
+ .filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
264
+ .map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
265
+ );
266
+ if (below.length > 0) {
267
+ io.error(`profile below threshold ${pct(options.failUnder)}: ${below.join(", ")}`);
268
+ return 2;
269
+ }
270
+ }
213
271
  if (regressions.length > 0) return 2;
214
272
  return 0;
215
273
  }
@@ -220,25 +278,40 @@ export async function runAblate(options, io = console) {
220
278
  const ablated = suite.tools.find((tool) => tool.name === options.tool);
221
279
  if (!ablated) throw new Error(`unknown tool "${options.tool}"`);
222
280
 
223
- const baseCases = selectCases(suite.cases, options.cases);
281
+ const scope = options.profiles && options.profiles.length > 0 ? ablationScope(suite, options.profiles) : null;
282
+ if (scope && !scope.tools.some((tool) => tool.name === ablated.name)) {
283
+ throw new Error(`tool "${ablated.name}" 不在组 "${scope.name}" 里`);
284
+ }
285
+
286
+ const selectedCases = selectCases(suite.cases, options.cases);
287
+ const baseTools = scope ? scope.tools : suite.tools;
288
+ const baseCases = scope
289
+ ? selectProfileCases(selectedCases, new Set(scope.tools.map((tool) => tool.name)))
290
+ : selectedCases;
224
291
  const reducedCases = baseCases.filter((testCase) => testCase.expectedTool !== ablated.name);
225
292
  const droppedCases = baseCases.length - reducedCases.length;
226
293
  const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
227
294
 
228
- const fullTools = toModelTools(suite.tools);
229
- const reducedTools = toModelTools(suite.tools.filter((tool) => tool.name !== ablated.name));
295
+ const fullTools = toModelTools(baseTools);
296
+ const reducedTools = toModelTools(baseTools.filter((tool) => tool.name !== ablated.name));
230
297
  const models = [];
231
298
 
232
299
  for (const adapter of adapters) {
233
300
  const pricing = registry[adapter.id]?.pricing ?? null;
234
- const fullResults = await evaluate(adapter, fullTools, baseCases);
301
+ const fullResults = (await evaluate(adapter, fullTools, baseCases)).results;
235
302
  const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
236
- const reducedResults = await evaluate(adapter, reducedTools, reducedCases);
303
+ const reducedResults = (await evaluate(adapter, reducedTools, reducedCases)).results;
237
304
  const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
238
305
  models.push(analyzeAblation(adapter.id, full, reduced));
239
306
  }
240
307
 
241
- const payload = { tool: ablated.name, suite: suite.name, droppedCases, models };
308
+ const payload = {
309
+ tool: ablated.name,
310
+ suite: suite.name,
311
+ droppedCases,
312
+ models,
313
+ ...(scope ? { profile: { name: scope.name, tools: baseTools.length } } : {})
314
+ };
242
315
  io.log(options.json ? JSON.stringify(payload, null, 2) : formatAblation(payload));
243
316
  return 0;
244
317
  }
@@ -271,7 +344,8 @@ export async function runScan(options, io = console) {
271
344
  source: server.source,
272
345
  origin: "mcp-probe",
273
346
  confidence: "high",
274
- note: `server ${server.name}`
347
+ note: `server ${server.name}`,
348
+ meta: { provider: server.name }
275
349
  });
276
350
  report.probes.push({ server: server.name, ok: true, toolCount: added.length, tools: added });
277
351
  }
@@ -333,7 +407,9 @@ export async function runCaseAdd(options, io = console) {
333
407
  }
334
408
 
335
409
  export async function runCoverage(options, io = console) {
336
- const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
410
+ // A scan draft has no cases yet; "what is still missing" is exactly the view
411
+ // a user needs right after scanning.
412
+ const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), { allowEmptyCases: true });
337
413
  const report = computeCoverage(suite);
338
414
  io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
339
415
  return 0;
@@ -346,7 +422,7 @@ export async function runSandboxCheck(options, io = console) {
346
422
  }
347
423
 
348
424
  export async function runLint(options, io = console) {
349
- const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
425
+ const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), { allowEmptyCases: true });
350
426
  const report = lintSuite(suite);
351
427
  io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
352
428
  return report.counts.error > 0 ? 1 : 0;
@@ -416,29 +492,44 @@ function autoSelect(registry) {
416
492
  .map(([id]) => id);
417
493
  }
418
494
 
419
- async function evaluate(adapter, tools, cases) {
495
+ async function evaluate(adapter, tools, cases, repeat = 1) {
420
496
  const results = [];
497
+ const unstable = [];
421
498
  for (const testCase of cases) {
422
- const messages = [
423
- { role: "system", content: SYSTEM_PROMPT },
424
- { role: "user", content: testCase.input }
425
- ];
426
- try {
427
- results.push(await adapter.call({ input: testCase.input, messages, tools }));
428
- } catch (error) {
429
- results.push({
430
- ok: false,
431
- error: String(error?.message ?? error),
432
- toolCalls: [],
433
- usage: { input: 0, output: 0 },
434
- latencyMs: 0
435
- });
499
+ const runs = [];
500
+ for (let attempt = 0; attempt < repeat; attempt += 1) {
501
+ runs.push(await callOnce(adapter, tools, testCase));
436
502
  }
503
+ if (repeat === 1) {
504
+ results.push(runs[0]);
505
+ continue;
506
+ }
507
+ const picked = pickRepresentative(runs);
508
+ results.push(picked.result);
509
+ if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
510
+ }
511
+ return { results, stability: repeat > 1 ? { runs: repeat, unstable } : null };
512
+ }
513
+
514
+ async function callOnce(adapter, tools, testCase) {
515
+ const messages = [
516
+ { role: "system", content: SYSTEM_PROMPT },
517
+ { role: "user", content: testCase.input }
518
+ ];
519
+ try {
520
+ return await adapter.call({ input: testCase.input, messages, tools });
521
+ } catch (error) {
522
+ return {
523
+ ok: false,
524
+ error: String(error?.message ?? error),
525
+ toolCalls: [],
526
+ usage: { input: 0, output: 0 },
527
+ latencyMs: 0
528
+ };
437
529
  }
438
- return results;
439
530
  }
440
531
 
441
- function serialize(suite, cases, summaries, executionInfo = null) {
532
+ function serialize(suite, cases, summaries, executionInfo = null, profileReport = null) {
442
533
  return {
443
534
  suite: {
444
535
  name: suite.name,
@@ -448,10 +539,105 @@ function serialize(suite, cases, summaries, executionInfo = null) {
448
539
  fingerprint: suiteFingerprint(suite, cases),
449
540
  ...(executionInfo ? { execution: executionInfo } : {})
450
541
  },
451
- models: summaries
542
+ models: summaries,
543
+ ...(profileReport ? { profiles: profileReport } : {})
544
+ };
545
+ }
546
+
547
+ function ablationScope(suite, names) {
548
+ if (names.length !== 1) throw new Error("mf ablate --profile 一次只能指定一个组");
549
+ const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
550
+ if (!profiles) {
551
+ throw new Error(`suite ${suite.name} 没有 profiles;先运行 mf cluster --out <suite> 生成分组`);
552
+ }
553
+ const [profile] = selectProfiles(profiles, names);
554
+ const mounted = new Set(profile.tools);
555
+ return { name: profile.name, tools: suite.tools.filter((tool) => mounted.has(tool.name)) };
556
+ }
557
+
558
+ async function evaluateProfiles({ suite, cases, runs, registry, selection, repeat = 1 }) {
559
+ const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
560
+ if (!profiles) {
561
+ throw new Error(`suite ${suite.name} 没有 profiles;先运行 mf cluster --out <suite> 生成分组`);
562
+ }
563
+ const selected = selectProfiles(profiles, selection);
564
+ const indexById = new Map(cases.map((testCase, index) => [testCase.id, index]));
565
+ const warnings = profileWarnings(selected, suite);
566
+
567
+ const groups = [];
568
+ const caseSets = selected.map((profile) => {
569
+ const profileCases = selectProfileCases(cases, new Set(profile.tools));
570
+ if (profileCases.length === 0) warnings.push(`profile "${profile.name}" 没有可评分的用例,已跳过`);
571
+ groups.push({ name: profile.name, tools: profile.tools.length, cases: profileCases.length });
572
+ return profileCases;
573
+ });
574
+ for (const row of profileCaseCoverage(selected, cases)) {
575
+ if (row.cases === 0) {
576
+ warnings.push(`组 ${row.name} 只有负例,mounted 分数只反映"不该调用",不反映工具选择`);
577
+ }
578
+ }
579
+
580
+ const models = [];
581
+ for (const run of runs) {
582
+ const pricing = registry[run.adapter.id]?.pricing ?? null;
583
+ const entries = [];
584
+ for (const [index, profile] of selected.entries()) {
585
+ const profileCases = caseSets[index];
586
+ if (profileCases.length === 0) continue;
587
+ const mountedTools = suite.tools.filter((tool) => profile.tools.includes(tool.name));
588
+ const mountedEval = await evaluate(run.adapter, toModelTools(mountedTools), profileCases, repeat);
589
+ const mounted = scoreModel({
590
+ model: run.adapter.id,
591
+ cases: profileCases,
592
+ results: mountedEval.results,
593
+ pricing
594
+ });
595
+ const allInOne = scoreModel({
596
+ model: run.adapter.id,
597
+ cases: profileCases,
598
+ results: profileCases.map((testCase) => run.results[indexById.get(testCase.id)]),
599
+ pricing
600
+ });
601
+ const entry = analyzeMount(profile, mounted, allInOne);
602
+ if (mountedEval.stability) entry.stability = mountedEval.stability;
603
+ entries.push(entry);
604
+ }
605
+ if (entries.length > 0 && entries.every((entry) => entry.reviewed === 0)) {
606
+ warnings.push("所有用例都未复核,分组分数不可用;把认可的用例 reviewed 改为 true 后再看");
607
+ }
608
+ models.push({ model: run.adapter.id, profiles: entries, totals: modelTotals(entries) });
609
+ }
610
+
611
+ return {
612
+ hash: hashOf(
613
+ selected.map((profile) => [profile.name, [...profile.tools].sort()]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
614
+ ),
615
+ groups,
616
+ warnings,
617
+ models
452
618
  };
453
619
  }
454
620
 
621
+ export async function runCluster(options, io = console) {
622
+ const suite = await loadSuite(options.suite, { allowEmptyCases: true });
623
+ const result = clusterTools(suite.tools, { threshold: options.threshold, maxSize: options.maxSize });
624
+ if (options.out) {
625
+ const target = resolve(options.out);
626
+ const profiles = Object.fromEntries(result.clusters.map((cluster) => [cluster.label, cluster.tools]));
627
+ // Keep the suite file as the author wrote it (refs, execution, extra keys)
628
+ // and only add profiles.
629
+ const raw = JSON.parse(await readFile(suite.path, "utf8"));
630
+ await writeFileAtomic(target, `${JSON.stringify({ ...raw, profiles }, null, 2)}\n`);
631
+ if (raw.profiles && Object.keys(raw.profiles).length > 0) {
632
+ io.log(`note: 覆盖原有 ${Object.keys(raw.profiles).length} 个分组,原 profiles 不保留`);
633
+ }
634
+ io.log(`profiles written: ${target}`);
635
+ }
636
+ if (options.json) io.log(JSON.stringify(result, null, 2));
637
+ else io.log(formatClusters(result));
638
+ return 0;
639
+ }
640
+
455
641
  function parseArgs(argv) {
456
642
  const options = {
457
643
  command: argv[0],
@@ -459,6 +645,8 @@ function parseArgs(argv) {
459
645
  models: null,
460
646
  modelsFile: DEFAULT_MODELS,
461
647
  cases: null,
648
+ profiles: null,
649
+ repeat: 1,
462
650
  showCases: false,
463
651
  json: false,
464
652
  out: null,
@@ -474,6 +662,8 @@ function parseArgs(argv) {
474
662
  reviewed: false,
475
663
  dryRun: false,
476
664
  tool: null,
665
+ threshold: 0.5,
666
+ maxSize: 10,
477
667
  baseline: null,
478
668
  failOnRegression: false,
479
669
  execute: false,
@@ -505,6 +695,13 @@ function parseArgs(argv) {
505
695
  case "--models": options.models = splitList(value()); break;
506
696
  case "--models-file": options.modelsFile = resolve(value()); break;
507
697
  case "--case": options.cases = (options.cases ?? []).concat(splitList(value())); break;
698
+ case "--profile": options.profiles = splitList(value()); break;
699
+ case "--repeat": {
700
+ const raw = Number(value());
701
+ if (!Number.isInteger(raw) || raw < 1 || raw > 10) throw new Error("--repeat expects an integer between 1 and 10");
702
+ options.repeat = raw;
703
+ break;
704
+ }
508
705
  case "--show-cases": options.showCases = true; break;
509
706
  case "--json": options.json = true; break;
510
707
  case "--out": options.out = value(); break;
@@ -524,6 +721,18 @@ function parseArgs(argv) {
524
721
  case "--reviewed": options.reviewed = true; break;
525
722
  case "--dry-run": options.dryRun = true; break;
526
723
  case "--tool": options.tool = value(); break;
724
+ case "--threshold": {
725
+ const raw = Number(value());
726
+ if (!Number.isFinite(raw) || raw <= 0 || raw > 1) throw new Error("--threshold expects a ratio between 0 and 1");
727
+ options.threshold = raw;
728
+ break;
729
+ }
730
+ case "--max-size": {
731
+ const raw = Number(value());
732
+ if (!Number.isInteger(raw) || raw <= 0) throw new Error("--max-size expects a positive integer");
733
+ options.maxSize = raw;
734
+ break;
735
+ }
527
736
  case "--baseline": options.baseline = resolve(value()); break;
528
737
  case "--fail-on-regression": options.failOnRegression = true; break;
529
738
  case "--entry": options.entry = value(); break;
@@ -605,7 +814,7 @@ if (isMain) {
605
814
  }
606
815
  } else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
607
816
  process.stdout.write(USAGE);
608
- } else if (!["eval", "scan", "case", "coverage", "lint", "ablate", "exec"].includes(argv[0])) {
817
+ } else if (!["eval", "scan", "case", "coverage", "lint", "ablate", "cluster", "exec"].includes(argv[0])) {
609
818
  process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
610
819
  process.exit(1);
611
820
  } else if (argv[0] === "case" && argv[1] !== "add") {
@@ -615,6 +824,10 @@ if (isMain) {
615
824
  try {
616
825
  const args = argv[0] === "case" ? [argv[0], ...argv.slice(2)] : argv;
617
826
  const options = parseArgs(args);
827
+ if (options.help) {
828
+ process.stdout.write(USAGE);
829
+ process.exit(0);
830
+ }
618
831
  const code =
619
832
  argv[0] === "scan"
620
833
  ? await runScan(options)
@@ -626,7 +839,9 @@ if (isMain) {
626
839
  ? await runLint(options)
627
840
  : argv[0] === "ablate"
628
841
  ? await runAblate(options)
629
- : argv[0] === "exec"
842
+ : argv[0] === "cluster"
843
+ ? await runCluster(options)
844
+ : argv[0] === "exec"
630
845
  ? await runExec(options)
631
846
  : await runEval(options);
632
847
  process.exit(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",