modelfunction-cli 0.0.0-stage → 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.
package/mf.js ADDED
@@ -0,0 +1,638 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadSuite, toModelTools } from "./lib/suite.js";
6
+ import { scoreModel, pct } from "./lib/score.js";
7
+ import { formatTable, formatCaseLines, formatMarkdown } from "./lib/report.js";
8
+ import { scanProject, buildSuiteDraft, formatScanSummary, addTools, redactReport } from "./lib/scan.js";
9
+ import { probeStdioServer } from "./lib/mcp-probe.js";
10
+ import { loadAdapterTools } from "./lib/adapter-import.js";
11
+ import { addCases, computeCoverage, formatCoverage } from "./lib/cases.js";
12
+ import { lintSuite, formatLint } from "./lib/lint.js";
13
+ import { suiteFingerprint, hashText } from "./lib/fingerprint.js";
14
+ import { runExecutionChecks } from "./lib/eval-exec.js";
15
+ import { checkSandbox, formatSandboxReport } from "./lib/sandbox-check.js";
16
+ import { compareScorecards, formatCompare } from "./lib/compare.js";
17
+ import { analyzeAblation, formatAblation } from "./lib/ablate.js";
18
+ import { runExec } from "./lib/exec.js";
19
+ import { writeFileAtomic } from "./lib/fsutil.js";
20
+ import { createMockAdapter } from "./lib/adapters/mock.js";
21
+ import { createOpenAiAdapter } from "./lib/adapters/openai.js";
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+ const DEFAULT_SUITE = resolve(HERE, "fixtures/suite.json");
25
+ const DEFAULT_MODELS = resolve(HERE, "fixtures/models.json");
26
+
27
+ const SYSTEM_PROMPT = [
28
+ "You are a tool-calling assistant.",
29
+ "Call exactly one tool when the user request clearly matches it.",
30
+ "Do not call any tool when no tool applies; ask a clarifying question instead of inventing arguments.",
31
+ "Only use values that appear in the user message."
32
+ ].join(" ");
33
+
34
+ const USAGE = `mf — function-calling accuracy scorecard
35
+
36
+ Usage:
37
+ mf eval [options]
38
+ mf scan [options]
39
+ mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
40
+ mf coverage [--suite <file>] [--json]
41
+ mf lint [--suite <file>] [--json]
42
+ mf compare <before.json> <after.json> [--json]
43
+ mf ablate --tool <name> [--suite <file>] [--models <ids>] [--json]
44
+ 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
+ mf sandbox check [--json] # probe available isolation engines on this machine
46
+ mf mcp # start stdio MCP server for coding agents
47
+
48
+ Eval options:
49
+ --suite <path> eval suite (default: built-in demo suite)
50
+ --models <ids> comma-separated ids from models.json (default: models with API keys set)
51
+ --models-file <path> model registry (default: built-in registry)
52
+ --case <ids> run only these case ids (comma-separated, repeatable)
53
+ --show-cases print per-case results
54
+ --json print JSON instead of the table
55
+ --out <file> write scorecard (.md => markdown, otherwise JSON)
56
+ --fail-under <ratio> exit 2 if any model's selection accuracy is below ratio (e.g. 0.8)
57
+ --baseline <file> previous scorecard JSON (used with --fail-on-regression)
58
+ --fail-on-regression exit 2 if any reviewed case regressed vs --baseline
59
+ --execute run toolInput and assert expect (needs --entry or suite.execution.entry)
60
+ --exec-timeout <ms> per execution timeout (suite.execution.timeoutMs or 10000)
61
+ --allow-side-effects allow executing tools declaring write/external/unknown sideEffects
62
+
63
+ Scan options:
64
+ --root <dir> repository to scan (default: .)
65
+ --from <file> import tools from a module (export default [...] or export const tools)
66
+ --probe start stdio MCP servers and read tools/list
67
+ --timeout <ms> probe timeout per server (default: 5000)
68
+ --out <file> suite draft to write (default: mf.suite.json)
69
+ --report <file> scan report to write (default: mf.scan.json)
70
+ --no-write print only, do not write files
71
+
72
+ Env keys: DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, ZHIPU_API_KEY, OPENAI_API_KEY, SILICONFLOW_API_KEY
73
+
74
+ Examples:
75
+ mf eval --models mock --show-cases
76
+ mf eval --models deepseek,qwen --out scorecard.md
77
+ mf scan
78
+ mf scan --root ../my-agent --no-write
79
+ mf case add --suite mf.suite.json --input cases.json
80
+ mf coverage --suite mf.suite.json
81
+ `;
82
+
83
+ export async function runEval(options, io = console) {
84
+ const suite = await loadSuite(options.suite);
85
+ const cases = selectCases(suite.cases, options.cases);
86
+ const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
87
+ const tools = toModelTools(suite.tools);
88
+
89
+ const summaries = [];
90
+ 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
+ );
100
+ }
101
+
102
+ let executionInfo = null;
103
+ if (options.execute) {
104
+ const entry = options.execEntry ?? options.entry ?? suite.execution?.entry;
105
+ if (!entry) throw new Error("--execute 需要 --entry,或套件中的 execution.entry");
106
+ await runExecutionChecks({
107
+ suite,
108
+ cases,
109
+ summaries,
110
+ options: {
111
+ ...options,
112
+ execEntry: entry,
113
+ execRuntime: options.runtime,
114
+ execPython: options.python
115
+ }
116
+ });
117
+ const entryPath = resolve(entry);
118
+ const runtime = suite.execution?.runtime ?? options.runtime ?? "node";
119
+ executionInfo = {
120
+ runtime,
121
+ engine: runtime === "python" ? "python-audit" : "node-permission",
122
+ entry: basename(entryPath),
123
+ hash: hashText(await readFile(entryPath, "utf8"))
124
+ };
125
+ }
126
+
127
+ if (options.out) {
128
+ const target = resolve(options.out);
129
+ const markdown = extname(target).toLowerCase() === ".md";
130
+ const payload = markdown
131
+ ? formatMarkdown(suite, cases, summaries)
132
+ : JSON.stringify(serialize(suite, cases, summaries, executionInfo), null, 2);
133
+ await writeFileAtomic(target, payload);
134
+ io.log(`wrote ${target}`);
135
+ }
136
+
137
+ if (options.json) {
138
+ io.log(JSON.stringify(serialize(suite, cases, summaries, executionInfo), null, 2));
139
+ } else {
140
+ io.log(`suite: ${suite.name} — ${cases.length} cases, ${suite.tools.length} tools`);
141
+ io.log(`file: ${suite.path}`);
142
+ for (const summary of summaries) {
143
+ io.log("");
144
+ if (options.showCases) {
145
+ io.log(`[${summary.model}]`);
146
+ for (const line of formatCaseLines(summary, cases)) io.log(` ${line}`);
147
+ io.log("");
148
+ }
149
+ io.log(formatTable([summary]));
150
+ if (summary.unreviewedTotal > 0) {
151
+ io.log(`note: ${summary.unreviewedTotal} unreviewed case(s) excluded from select/args/pass`);
152
+ }
153
+ if (summary.exec) {
154
+ const parts = [
155
+ `exec: ${summary.exec.passed}/${summary.exec.executed} passed`,
156
+ `engine ${summary.exec.engine}`
157
+ ];
158
+ if (summary.exec.skipped > 0) {
159
+ parts.push(
160
+ `${summary.exec.skipped} skipped${summary.exec.blocked > 0 ? `(${summary.exec.blocked} 因 sideEffects 被拦截,需 --allow-side-effects)` : ""}`
161
+ );
162
+ }
163
+ if (summary.exec.p50DurationMs !== null) parts.push(`p50 ${summary.exec.p50DurationMs}ms`);
164
+ if (summary.exec.maxPeakRssMB !== null) parts.push(`peak ${summary.exec.maxPeakRssMB}MB`);
165
+ io.log(parts.join(", "));
166
+ }
167
+ }
168
+ if (summaries.length > 1) {
169
+ io.log("");
170
+ io.log("combined");
171
+ io.log(formatTable(summaries));
172
+ }
173
+ if (summaries.some((summary) => summary.rows.some((row) => row.usage?.estimated))) {
174
+ io.log("");
175
+ io.log("note: tokens marked with ~ are estimated (mock adapter); pricing in models.json is indicative.");
176
+ }
177
+ }
178
+
179
+ let regressions = [];
180
+ if (options.failOnRegression) {
181
+ if (!options.baseline) {
182
+ io.error("--fail-on-regression requires --baseline <scorecard.json>");
183
+ return 2;
184
+ }
185
+ const baseline = JSON.parse(await readFile(resolve(options.baseline), "utf8"));
186
+ const comparison = compareScorecards(baseline, serialize(suite, cases, summaries, executionInfo));
187
+ if (!options.json) {
188
+ io.log("");
189
+ io.log(formatCompare(comparison));
190
+ }
191
+ const comparable = comparison.models.filter((model) => model.comparable && model.reviewed > 0);
192
+ if (comparable.length === 0) {
193
+ io.error("定义或用例已变更,无法判定回归(门禁跳过)");
194
+ } else {
195
+ regressions = comparable.flatMap((model) =>
196
+ model.regressions.map((item) => `${model.model}/${item.id}`)
197
+ );
198
+ if (regressions.length > 0) io.error(`regressions: ${regressions.join(", ")}`);
199
+ }
200
+ }
201
+
202
+ const failed = options.failUnder === null
203
+ ? []
204
+ : summaries.filter((summary) => summary.selectionAccuracy < options.failUnder);
205
+ if (failed.length > 0) {
206
+ io.error(
207
+ `below threshold ${pct(options.failUnder)}: ${failed
208
+ .map((summary) => `${summary.model} (${pct(summary.selectionAccuracy)})`)
209
+ .join(", ")}`
210
+ );
211
+ return 2;
212
+ }
213
+ if (regressions.length > 0) return 2;
214
+ return 0;
215
+ }
216
+
217
+ export async function runAblate(options, io = console) {
218
+ const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
219
+ if (!options.tool) throw new Error("--tool is required");
220
+ const ablated = suite.tools.find((tool) => tool.name === options.tool);
221
+ if (!ablated) throw new Error(`unknown tool "${options.tool}"`);
222
+
223
+ const baseCases = selectCases(suite.cases, options.cases);
224
+ const reducedCases = baseCases.filter((testCase) => testCase.expectedTool !== ablated.name);
225
+ const droppedCases = baseCases.length - reducedCases.length;
226
+ const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
227
+
228
+ const fullTools = toModelTools(suite.tools);
229
+ const reducedTools = toModelTools(suite.tools.filter((tool) => tool.name !== ablated.name));
230
+ const models = [];
231
+
232
+ for (const adapter of adapters) {
233
+ const pricing = registry[adapter.id]?.pricing ?? null;
234
+ const fullResults = await evaluate(adapter, fullTools, baseCases);
235
+ const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
236
+ const reducedResults = await evaluate(adapter, reducedTools, reducedCases);
237
+ const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
238
+ models.push(analyzeAblation(adapter.id, full, reduced));
239
+ }
240
+
241
+ const payload = { tool: ablated.name, suite: suite.name, droppedCases, models };
242
+ io.log(options.json ? JSON.stringify(payload, null, 2) : formatAblation(payload));
243
+ return 0;
244
+ }
245
+
246
+ export async function runScan(options, io = console) {
247
+ const report = await scanProject(options.root ?? ".");
248
+
249
+ if (options.from) {
250
+ const tools = await loadAdapterTools(options.from);
251
+ addTools(report, tools, {
252
+ source: relative(process.cwd(), resolve(options.from)) || options.from,
253
+ origin: "adapter",
254
+ confidence: "high",
255
+ note: "从用户模块动态导入,schema 为运行时真实值"
256
+ });
257
+ }
258
+
259
+ if (options.probe) {
260
+ for (const server of [...report.mcpServers]) {
261
+ if (server.transport !== "stdio") {
262
+ report.probes.push({ server: server.name, ok: false, error: "http transport 暂不支持" });
263
+ continue;
264
+ }
265
+ const result = await probeStdioServer(server, { timeoutMs: options.timeoutMs });
266
+ if (!result.ok) {
267
+ report.probes.push({ server: server.name, ok: false, error: result.error, stderr: result.stderr });
268
+ continue;
269
+ }
270
+ const added = addTools(report, result.tools, {
271
+ source: server.source,
272
+ origin: "mcp-probe",
273
+ confidence: "high",
274
+ note: `server ${server.name}`
275
+ });
276
+ report.probes.push({ server: server.name, ok: true, toolCount: added.length, tools: added });
277
+ }
278
+ }
279
+
280
+ const draft = buildSuiteDraft(report);
281
+
282
+ if (options.json) {
283
+ io.log(JSON.stringify({ report: redactReport(report), draft }, null, 2));
284
+ return report.tools.length > 0 ? 0 : 3;
285
+ }
286
+
287
+ io.log(formatScanSummary(report));
288
+
289
+ if (!options.noWrite) {
290
+ const suitePath = resolve(options.out ?? "mf.suite.json");
291
+ const reportPath = resolve(options.report ?? "mf.scan.json");
292
+ await writeFileAtomic(suitePath, JSON.stringify(draft, null, 2));
293
+ await writeFileAtomic(reportPath, JSON.stringify(redactReport(report), null, 2));
294
+ io.log("");
295
+ io.log(`wrote ${suitePath}`);
296
+ io.log(`wrote ${reportPath}`);
297
+ }
298
+
299
+ return report.tools.length > 0 ? 0 : 3;
300
+ }
301
+
302
+ export async function runCaseAdd(options, io = console) {
303
+ const suitePath = resolve(options.suite ?? DEFAULT_SUITE);
304
+ let payload;
305
+ if (options.inputPath) {
306
+ payload = JSON.parse(await readFile(resolve(options.inputPath), "utf8"));
307
+ } else {
308
+ payload = JSON.parse(await readStdin());
309
+ }
310
+
311
+ const result = await addCases(suitePath, payload, {
312
+ source: options.source,
313
+ reviewed: options.reviewed,
314
+ dryRun: options.dryRun
315
+ });
316
+
317
+ io.log(`suite: ${result.path}`);
318
+ io.log(`added: ${result.added.length}${result.dryRun ? " (dry-run)" : ""}`);
319
+ for (const testCase of result.added) {
320
+ io.log(
321
+ ` + ${testCase.id} → ${testCase.expectedTool ?? "(no tool)"} [${testCase.source}${testCase.reviewed ? ", reviewed" : ""}]`
322
+ );
323
+ }
324
+ if (result.warnings.length > 0) {
325
+ for (const warning of result.warnings) io.error(`warn: ${warning}`);
326
+ }
327
+ if (result.rejected.length > 0) {
328
+ io.error(`rejected: ${result.rejected.length}`);
329
+ for (const item of result.rejected) io.error(` - ${item.id}: ${item.reason}`);
330
+ return 1;
331
+ }
332
+ return 0;
333
+ }
334
+
335
+ export async function runCoverage(options, io = console) {
336
+ const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
337
+ const report = computeCoverage(suite);
338
+ io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
339
+ return 0;
340
+ }
341
+
342
+ export async function runSandboxCheck(options, io = console) {
343
+ const report = checkSandbox();
344
+ io.log(options.json ? JSON.stringify(report, null, 2) : formatSandboxReport(report));
345
+ return 0;
346
+ }
347
+
348
+ export async function runLint(options, io = console) {
349
+ const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
350
+ const report = lintSuite(suite);
351
+ io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
352
+ return report.counts.error > 0 ? 1 : 0;
353
+ }
354
+
355
+ export async function runCompare(options, io = console) {
356
+ const [beforePath, afterPath] = options.paths;
357
+ const before = JSON.parse(await readFile(resolve(beforePath), "utf8"));
358
+ const after = JSON.parse(await readFile(resolve(afterPath), "utf8"));
359
+ const report = compareScorecards(before, after);
360
+ io.log(options.json ? JSON.stringify(report, null, 2) : formatCompare(report));
361
+ const changed = report.toolChanges.length > 0 || report.caseChanges.length > 0;
362
+ return changed ? 1 : 0;
363
+ }
364
+
365
+ async function readStdin() {
366
+ const chunks = [];
367
+ for await (const chunk of process.stdin) chunks.push(chunk);
368
+ return Buffer.concat(chunks).toString("utf8");
369
+ }
370
+
371
+ function selectCases(cases, ids) {
372
+ if (!ids || ids.length === 0) return cases;
373
+ const wanted = new Set(ids);
374
+ const missing = ids.filter((id) => !cases.some((testCase) => testCase.id === id));
375
+ if (missing.length > 0) throw new Error(`unknown case ids: ${missing.join(", ")}`);
376
+ return cases.filter((testCase) => wanted.has(testCase.id));
377
+ }
378
+
379
+ async function resolveModels(ids, modelsFile, io) {
380
+ const registry = JSON.parse(await readFile(resolve(modelsFile), "utf8"));
381
+ const explicit = Boolean(ids && ids.length > 0);
382
+ const chosen = explicit ? ids : autoSelect(registry);
383
+ const adapters = [];
384
+
385
+ for (const id of chosen) {
386
+ const config = registry[id];
387
+ if (!config) {
388
+ throw new Error(`unknown model "${id}" (available: ${Object.keys(registry).join(", ")})`);
389
+ }
390
+ if (config.type === "mock") {
391
+ adapters.push({ id, ...createMockAdapter() });
392
+ continue;
393
+ }
394
+ if (config.type !== "openai") {
395
+ throw new Error(`model "${id}": unsupported type "${config.type}"`);
396
+ }
397
+ if (!process.env[config.apiKeyEnv]) {
398
+ if (explicit) throw new Error(`model "${id}": environment variable ${config.apiKeyEnv} is not set`);
399
+ io.error(`skip ${id}: ${config.apiKeyEnv} is not set`);
400
+ continue;
401
+ }
402
+ adapters.push({ id, ...createOpenAiAdapter({ id, ...config }) });
403
+ }
404
+
405
+ if (adapters.length === 0) {
406
+ if (explicit) throw new Error("no runnable models");
407
+ io.error("no API keys found; falling back to the mock adapter");
408
+ adapters.push({ id: "mock", ...createMockAdapter() });
409
+ }
410
+ return { registry, adapters };
411
+ }
412
+
413
+ function autoSelect(registry) {
414
+ return Object.entries(registry)
415
+ .filter(([, config]) => config.type === "openai" && process.env[config.apiKeyEnv])
416
+ .map(([id]) => id);
417
+ }
418
+
419
+ async function evaluate(adapter, tools, cases) {
420
+ const results = [];
421
+ 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
+ });
436
+ }
437
+ }
438
+ return results;
439
+ }
440
+
441
+ function serialize(suite, cases, summaries, executionInfo = null) {
442
+ return {
443
+ suite: {
444
+ name: suite.name,
445
+ path: suite.path,
446
+ tools: suite.tools.map((tool) => tool.name),
447
+ cases: cases.length,
448
+ fingerprint: suiteFingerprint(suite, cases),
449
+ ...(executionInfo ? { execution: executionInfo } : {})
450
+ },
451
+ models: summaries
452
+ };
453
+ }
454
+
455
+ function parseArgs(argv) {
456
+ const options = {
457
+ command: argv[0],
458
+ suite: DEFAULT_SUITE,
459
+ models: null,
460
+ modelsFile: DEFAULT_MODELS,
461
+ cases: null,
462
+ showCases: false,
463
+ json: false,
464
+ out: null,
465
+ failUnder: null,
466
+ root: ".",
467
+ report: null,
468
+ noWrite: false,
469
+ from: null,
470
+ probe: false,
471
+ timeoutMs: 5000,
472
+ inputPath: null,
473
+ source: "agent",
474
+ reviewed: false,
475
+ dryRun: false,
476
+ tool: null,
477
+ baseline: null,
478
+ failOnRegression: false,
479
+ execute: false,
480
+ execEntry: null,
481
+ execTimeoutMs: 10_000,
482
+ allowSideEffects: false,
483
+ entry: null,
484
+ runtime: "node",
485
+ python: "python",
486
+ exportName: "run",
487
+ inputJson: null,
488
+ allowRead: [],
489
+ allowWrite: [],
490
+ allowNet: [],
491
+ passEnv: [],
492
+ warmup: 0
493
+ };
494
+
495
+ for (let i = 1; i < argv.length; i += 1) {
496
+ const arg = argv[i];
497
+ const value = () => {
498
+ const next = argv[i + 1];
499
+ if (next === undefined || next.startsWith("--")) throw new Error(`missing value for ${arg}`);
500
+ i += 1;
501
+ return next;
502
+ };
503
+ switch (arg) {
504
+ case "--suite": options.suite = resolve(value()); break;
505
+ case "--models": options.models = splitList(value()); break;
506
+ case "--models-file": options.modelsFile = resolve(value()); break;
507
+ case "--case": options.cases = (options.cases ?? []).concat(splitList(value())); break;
508
+ case "--show-cases": options.showCases = true; break;
509
+ case "--json": options.json = true; break;
510
+ case "--out": options.out = value(); break;
511
+ case "--root": options.root = resolve(value()); break;
512
+ case "--from": options.from = value(); break;
513
+ case "--probe": options.probe = true; break;
514
+ case "--timeout": {
515
+ const raw = Number(value());
516
+ if (!Number.isFinite(raw) || raw <= 0) throw new Error("--timeout expects a positive number of milliseconds");
517
+ options.timeoutMs = raw;
518
+ break;
519
+ }
520
+ case "--report": options.report = value(); break;
521
+ case "--no-write": options.noWrite = true; break;
522
+ case "--input": options.inputPath = value(); break;
523
+ case "--source": options.source = value(); break;
524
+ case "--reviewed": options.reviewed = true; break;
525
+ case "--dry-run": options.dryRun = true; break;
526
+ case "--tool": options.tool = value(); break;
527
+ case "--baseline": options.baseline = resolve(value()); break;
528
+ case "--fail-on-regression": options.failOnRegression = true; break;
529
+ case "--entry": options.entry = value(); break;
530
+ case "--execute": options.execute = true; break;
531
+ case "--exec-timeout": options.execTimeoutMs = Number(value()) || 10_000; break;
532
+ case "--allow-side-effects": options.allowSideEffects = true; break;
533
+ case "--runtime": options.runtime = value(); break;
534
+ case "--python": options.python = value(); break;
535
+ case "--export": options.exportName = value(); break;
536
+ case "--input-json": options.inputJson = value(); break;
537
+ case "--allow-read": options.allowRead = splitList(value()); break;
538
+ case "--allow-write": options.allowWrite = splitList(value()); break;
539
+ case "--allow-net": options.allowNet = splitList(value()); break;
540
+ case "--pass-env": options.passEnv = splitList(value()); break;
541
+ case "--warmup": options.warmup = Number(value()) || 0; break;
542
+ case "--fail-under": {
543
+ const raw = Number(value());
544
+ if (!Number.isFinite(raw) || raw < 0 || raw > 1) throw new Error("--fail-under expects a ratio between 0 and 1");
545
+ options.failUnder = raw;
546
+ break;
547
+ }
548
+ case "-h":
549
+ case "--help":
550
+ options.help = true;
551
+ break;
552
+ default:
553
+ throw new Error(`unknown option "${arg}"`);
554
+ }
555
+ }
556
+ return options;
557
+ }
558
+
559
+ function splitList(value) {
560
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
561
+ }
562
+
563
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
564
+
565
+ if (isMain) {
566
+ let argv = process.argv.slice(2);
567
+ // `npx modelfunction-cli mf scan` passes the bin name through; accept it.
568
+ if (argv[0] === "mf") argv = argv.slice(1);
569
+ if (argv[0] === "--version" || argv[0] === "-v") {
570
+ const pkg = JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8"));
571
+ process.stdout.write(`mf ${pkg.version}\n`);
572
+ process.exit(0);
573
+ } else if (argv[0] === "sandbox") {
574
+ if (argv[1] !== "check") {
575
+ process.stderr.write("usage: mf sandbox check [--json]\n");
576
+ process.exit(1);
577
+ }
578
+ try {
579
+ const code = await runSandboxCheck({ json: argv.includes("--json") });
580
+ process.exit(code);
581
+ } catch (error) {
582
+ process.stderr.write(`${error?.message ?? error}\n`);
583
+ process.exit(1);
584
+ }
585
+ } else if (argv[0] === "compare") {
586
+ const paths = argv.slice(1).filter((arg) => !arg.startsWith("--"));
587
+ if (paths.length !== 2) {
588
+ process.stderr.write("usage: mf compare <before.json> <after.json> [--json]\n");
589
+ process.exit(1);
590
+ }
591
+ try {
592
+ const code = await runCompare({ paths, json: argv.includes("--json") });
593
+ process.exit(code);
594
+ } catch (error) {
595
+ process.stderr.write(`${error?.message ?? error}\n`);
596
+ process.exit(1);
597
+ }
598
+ } else if (argv[0] === "mcp") {
599
+ try {
600
+ const { startMcpServer } = await import("./lib/mcp-server.js");
601
+ await startMcpServer();
602
+ } catch (error) {
603
+ process.stderr.write(`${error?.message ?? error}\n`);
604
+ process.exit(1);
605
+ }
606
+ } else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
607
+ process.stdout.write(USAGE);
608
+ } else if (!["eval", "scan", "case", "coverage", "lint", "ablate", "exec"].includes(argv[0])) {
609
+ process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
610
+ process.exit(1);
611
+ } else if (argv[0] === "case" && argv[1] !== "add") {
612
+ process.stderr.write(`usage: mf case add --suite <file> (--input <file> | stdin)\n`);
613
+ process.exit(1);
614
+ } else {
615
+ try {
616
+ const args = argv[0] === "case" ? [argv[0], ...argv.slice(2)] : argv;
617
+ const options = parseArgs(args);
618
+ const code =
619
+ argv[0] === "scan"
620
+ ? await runScan(options)
621
+ : argv[0] === "case"
622
+ ? await runCaseAdd(options)
623
+ : argv[0] === "coverage"
624
+ ? await runCoverage(options)
625
+ : argv[0] === "lint"
626
+ ? await runLint(options)
627
+ : argv[0] === "ablate"
628
+ ? await runAblate(options)
629
+ : argv[0] === "exec"
630
+ ? await runExec(options)
631
+ : await runEval(options);
632
+ process.exit(code);
633
+ } catch (error) {
634
+ process.stderr.write(`${error?.message ?? error}\n`);
635
+ process.exit(1);
636
+ }
637
+ }
638
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "mf — function-calling accuracy scorecard for AI agents",
7
+ "keywords": [
8
+ "llm",
9
+ "function-calling",
10
+ "tool-use",
11
+ "mcp",
12
+ "eval",
13
+ "agent"
14
+ ],
15
+ "bin": {
16
+ "mf": "mf.js"
17
+ },
18
+ "files": [
19
+ "mf.js",
20
+ "lib",
21
+ "fixtures",
22
+ "skills",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "test": "node --test \"test/**/*.test.js\"",
28
+ "eval": "node mf.js eval",
29
+ "eval:mock": "node mf.js eval --models mock --show-cases",
30
+ "pack:check": "npm pack --dry-run",
31
+ "prepublishOnly": "npm test"
32
+ },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.32.2",
38
+ "zod": "^4.6.7"
39
+ }
40
+ }