modelfunction-cli 0.4.3 → 0.5.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ## 安装
6
6
 
7
7
  ```bash
8
- npx -y modelfunction-cli@0.4.3 --version
8
+ npx -y modelfunction-cli@0.4.4 --version
9
9
  # 或
10
10
  npm i -D modelfunction-cli
11
11
  npx mf --version
@@ -38,9 +38,19 @@ SILICONFLOW_API_KEY=... mf eval --models dsv4-flash
38
38
  OPENAI_API_KEY=... mf eval --models openai
39
39
  ```
40
40
 
41
+ 也可以把 Key 存进用户级凭证文件,输入时隐藏、不进 shell history:
42
+
43
+ ```bash
44
+ mf config set-key DEEPSEEK_API_KEY # 粘贴 Key,回车;只显示掩码
45
+ mf config list # DEEPSEEK_API_KEY sk-…abcd
46
+ mf config path # ~/.modelfunction/credentials.json(0600)
47
+ ```
48
+
49
+ 加载优先级:**进程环境变量 > 凭证文件**(只补缺失项,不覆盖)。CI 与企业环境继续用环境变量或密钥管理,不要落盘。
50
+
41
51
  ### 自建 / 内网网关
42
52
 
43
- 企业内网基本都是 OpenAI 兼容协议,写一个 models-file 即可,Key 只走环境变量、不落盘:
53
+ 企业内网基本都是 OpenAI 兼容协议,写一个 models-file 即可(Key 用环境变量,或用 `mf config set-key` 存在本机):
44
54
 
45
55
  ```json
46
56
  {
@@ -76,6 +86,9 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
76
86
  | `mf exec` | 单工具执行 + 指标(Node / Python),支持 `--allow-read/write/net`、超时、sideEffects 准入 |
77
87
  | `mf eval --execute` | 一次跑完选择 + 参数 + 运行断言(`toolInput` / `expect`) |
78
88
  | `mf sandbox check` | 探测本机可用隔离引擎与降级建议 |
89
+ | `mf config set-key` | 把模型 Key 写入 `~/.modelfunction/credentials.json`(隐藏输入);`remove-key` 删除;`list` 只显示掩码,`path` 打印路径 |
90
+ | `mf stats` | 本地使用摘要(命令次数、模型、通过率区间、lint 规则命中);不联网 |
91
+ | `mf feedback` | 导出脱敏反馈包(仅计数与比率),发不发由你决定 |
79
92
  | `mf mcp` | stdio MCP server,供 Claude Code / Cursor / Kimi Code 调用 |
80
93
 
81
94
  ## 单次结果会抖:用 `--repeat` 看稳定性
@@ -111,7 +124,7 @@ mf eval --suite mf.suite.json --models internal-ds \
111
124
  "mcpServers": {
112
125
  "modelfunction": {
113
126
  "command": "npx",
114
- "args": ["-y", "modelfunction-cli@0.4.3", "mcp"]
127
+ "args": ["-y", "modelfunction-cli@0.4.4", "mcp"]
115
128
  }
116
129
  }
117
130
  }
@@ -123,12 +136,27 @@ mf eval --suite mf.suite.json --models internal-ds \
123
136
 
124
137
  对外 JSON 字段、`check()` 返回结构、MCP 参数与退出码见 [SCHEMA.md](SCHEMA.md),自 0.4.3 起冻结为 v1:v1 内只做加法,重命名或删除字段需 major 并提前两个 minor 或 90 天弃用。`check()` 的类型声明随包发布。
125
138
 
139
+ 本仓库当前版本 `0.5.0`(凭证与本地反馈渠道,待发布);上文的 `@0.4.4` 是 npm 上已发布的版本,保证示例可复制。发布后把示例里的版本号统一升级即可。
140
+
126
141
  ## 数据边界
127
142
 
128
143
  - 本地运行,无遥测、无服务端地址
129
144
  - 唯一出站:你配置的模型端点,以及你自己的 MCP server(`--probe` 时)
130
145
  - 评测会把用例输入与工具描述发给被测模型供应商——敏感数据请选境内供应商或本地模型
131
146
 
147
+ ## 本地使用记录与反馈(默认不上传)
148
+
149
+ 每条命令会在 `~/.modelfunction/usage/YYYY-MM.jsonl` 追加一行**计数记录**:命令名、退出码、耗时、mf/Node/OS 版本、模型 id、用例/工具数量、通过率区间、lint 规则命中数。**不记录**用例原文、工具描述、源码、文件路径或密钥。
150
+
151
+ ```bash
152
+ mf stats # 看本机记录;--json 给脚本
153
+ mf feedback --out out.json # 导出脱敏反馈包(聚合值,不含原始事件)
154
+ ```
155
+
156
+ - 默认无网络、无遥测;`MF_NO_USAGE=1` 关闭记录;直接删除 `~/.modelfunction/usage/` 即清空。
157
+ - 反馈包发不发由你决定;我们想知道的是「哪些命令在用、评测通过率大概什么水平、lint 命中哪些规则」,不需要项目细节。
158
+ - `MF_CONFIG_DIR` 可改整个配置目录位置(CI/容器里常用)。
159
+
132
160
  ## 用例从哪来
133
161
 
134
162
  由你或你的编码 Agent 撰写,规则模板见包内 `skills/mf-cases/SKILL.md`。未复核用例(`reviewed:false`)不计入正式分数。
@@ -137,7 +165,7 @@ mf eval --suite mf.suite.json --models internal-ds \
137
165
 
138
166
  ```bash
139
167
  # 1. 把 mf 挂成 MCP server
140
- claude mcp add modelfunction -- npx -y modelfunction-cli@0.4.3 mcp
168
+ claude mcp add modelfunction -- npx -y modelfunction-cli@0.4.4 mcp
141
169
  ```
142
170
 
143
171
  ```text
package/SCHEMA.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Schemas v1
2
2
 
3
- Frozen at `modelfunction-cli@0.4.3`。这里描述 CLI 与库对外的稳定契约。人类可读文本(表格、提示语)不属于契约;**JSON 字段**属于。
3
+ 自 `modelfunction-cli@0.4.3` 起冻结为 v1;`0.4.4` 是同一 v1 的补丁;`0.5.0` 增加 §9 本地文件,仍属 v1。这里描述 CLI 与库对外的稳定契约。人类可读文本(表格、提示语)不属于契约;**JSON 字段**属于。
4
4
 
5
5
  ## 兼容政策
6
6
 
@@ -198,3 +198,65 @@ import { check } from "modelfunction-cli";
198
198
  | 2 | `mf eval --fail-under` 未达标,或 `--fail-on-regression` 发现回退 |
199
199
  | 3 | `mf exec` 被权限策略拒绝;`mf scan --json` 未找到工具 |
200
200
  | 4 | `mf exec` 超时 |
201
+
202
+ ## 9. 本地文件(0.5.0 起)
203
+
204
+ ### `~/.modelfunction/credentials.json`
205
+
206
+ `MF_CONFIG_DIR` 可覆盖目录。扁平 JSON 对象,键为环境变量名、值为字符串:
207
+
208
+ ```json
209
+ { "DEEPSEEK_API_KEY": "sk-…" }
210
+ ```
211
+
212
+ 权限 0600(Windows 尽力)。加载规则:进程环境变量优先;凭证文件只补模型 registry 中声明的 `apiKeyEnv` 缺失项,不会向进程注入任意变量。不要提交到仓库;CI/企业用环境变量或密钥管理。
213
+
214
+ ### `~/.modelfunction/usage/YYYY-MM.jsonl`
215
+
216
+ 每行一个 JSON 事件(UTF-8,按 UTC 月份分文件;`ts` 为 ISO 8601):
217
+
218
+ ```json
219
+ {
220
+ "schema": 1,
221
+ "ts": "2026-11-01T01:26:33.000Z",
222
+ "command": "eval",
223
+ "exitCode": 0,
224
+ "durationMs": 1200,
225
+ "mf": "0.5.0",
226
+ "platform": "win32",
227
+ "arch": "x64",
228
+ "node": "24.6.0",
229
+ "models": ["mock"],
230
+ "caseCount": 13,
231
+ "toolCount": 4,
232
+ "reviewedCount": 13,
233
+ "repeat": 1,
234
+ "profileGroups": 0,
235
+ "selectionAccuracy": 0.92,
236
+ "argAccuracy": 1,
237
+ "passRate": 0.92,
238
+ "unstableCases": 0,
239
+ "failedThreshold": false,
240
+ "regressions": false
241
+ }
242
+ ```
243
+
244
+ 字段为白名单,只含计数、比率与 id;**不含**用例原文、工具描述、源码、文件路径或密钥。新增字段属于 v1 加法,消费方可忽略未知字段。`MF_NO_USAGE=1` 关闭写入;删除 `usage/` 即清空。
245
+
246
+ ### `mf feedback` 包
247
+
248
+ 默认写到 `MF_CONFIG_DIR/feedback/mf-feedback-YYYYMMDD-HHmmss.json`(UTC):
249
+
250
+ ```json
251
+ {
252
+ "schema": 1,
253
+ "kind": "modelfunction-feedback",
254
+ "generatedAt": "2026-11-01T01:26:33.000Z",
255
+ "mf": { "version": "0.5.0", "node": "24.6.0", "platform": "win32", "arch": "x64" },
256
+ "usage": { "runs": 1, "commands": { "eval": 1 } },
257
+ "privacy": "…",
258
+ "request": "…"
259
+ }
260
+ ```
261
+
262
+ `usage` 是 `mf stats` 的聚合结果,不含原始事件、路径或密钥。是否发送由用户决定。
@@ -0,0 +1,121 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { writeFileAtomic } from "./fsutil.js";
5
+
6
+ const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
7
+ const FILE_MODE = 0o600;
8
+ const DIR_MODE = 0o700;
9
+ const SECRET_PATTERNS = [
10
+ { pattern: /(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, replacement: "$1***" },
11
+ { pattern: /\bsk-[A-Za-z0-9_-]{8,}/g, replacement: "***" }
12
+ ];
13
+
14
+ const registeredSecrets = new Set();
15
+
16
+ export function configDir() {
17
+ const override = process.env.MF_CONFIG_DIR;
18
+ return override && override.trim() ? resolve(override.trim()) : join(homedir(), ".modelfunction");
19
+ }
20
+
21
+ export function credentialsPath() {
22
+ return join(configDir(), "credentials.json");
23
+ }
24
+
25
+ export async function readCredentials() {
26
+ let raw;
27
+ try {
28
+ raw = await readFile(credentialsPath(), "utf8");
29
+ } catch (error) {
30
+ if (error.code === "ENOENT") return {};
31
+ throw error;
32
+ }
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(raw);
36
+ } catch {
37
+ // Never echo the file content: it holds secrets.
38
+ throw new Error(`credentials file is not valid JSON: ${credentialsPath()}`);
39
+ }
40
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
41
+ throw new Error(`credentials file must be a JSON object: ${credentialsPath()}`);
42
+ }
43
+ const out = {};
44
+ for (const [name, value] of Object.entries(parsed)) {
45
+ if (typeof value === "string" && value.length > 0) out[name] = value;
46
+ }
47
+ registerSecrets(Object.values(out));
48
+ return out;
49
+ }
50
+
51
+ export async function setCredential(name, value) {
52
+ if (!ENV_NAME.test(name)) {
53
+ throw new Error(`invalid environment variable name "${name}" (expected e.g. DEEPSEEK_API_KEY)`);
54
+ }
55
+ const trimmed = String(value ?? "").trim();
56
+ if (!trimmed) throw new Error("empty value; nothing written");
57
+ if (/[\r\n]/.test(trimmed)) throw new Error("value must be a single line");
58
+ const existing = await readCredentials();
59
+ existing[name] = trimmed;
60
+ await mkdir(configDir(), { recursive: true, mode: DIR_MODE });
61
+ await writeFileAtomic(credentialsPath(), `${JSON.stringify(existing, null, 2)}\n`, { mode: FILE_MODE });
62
+ registerSecrets([trimmed]);
63
+ return { name, path: credentialsPath() };
64
+ }
65
+
66
+ export async function removeCredential(name) {
67
+ if (!ENV_NAME.test(name)) {
68
+ throw new Error(`invalid environment variable name "${name}" (expected e.g. DEEPSEEK_API_KEY)`);
69
+ }
70
+ const existing = await readCredentials();
71
+ if (!(name in existing)) return { name, removed: false, path: credentialsPath() };
72
+ delete existing[name];
73
+ await mkdir(configDir(), { recursive: true, mode: DIR_MODE });
74
+ await writeFileAtomic(credentialsPath(), `${JSON.stringify(existing, null, 2)}\n`, { mode: FILE_MODE });
75
+ return { name, removed: true, path: credentialsPath() };
76
+ }
77
+
78
+ export async function listCredentials() {
79
+ const stored = await readCredentials();
80
+ return Object.entries(stored)
81
+ .map(([name, value]) => ({ name, masked: maskSecret(value) }))
82
+ .sort((a, b) => a.name.localeCompare(b.name));
83
+ }
84
+
85
+ // Only fill keys the model registry actually reads; a credentials file must
86
+ // not be able to inject arbitrary environment variables into the process.
87
+ export async function applyModelCredentials(registry) {
88
+ const stored = await readCredentials();
89
+ const names = new Set(
90
+ Object.values(registry)
91
+ .map((config) => config?.apiKeyEnv)
92
+ .filter((name) => typeof name === "string" && name.length > 0)
93
+ );
94
+ const filled = [];
95
+ for (const name of names) {
96
+ if (!process.env[name] && stored[name]) {
97
+ process.env[name] = stored[name];
98
+ filled.push(name);
99
+ }
100
+ }
101
+ return filled;
102
+ }
103
+
104
+ export function maskSecret(value) {
105
+ const text = String(value ?? "");
106
+ if (text.length <= 8) return "********";
107
+ return `${text.slice(0, 3)}…${text.slice(-4)}`;
108
+ }
109
+
110
+ export function registerSecrets(values) {
111
+ for (const value of values ?? []) {
112
+ if (typeof value === "string" && value.length >= 8) registeredSecrets.add(value);
113
+ }
114
+ }
115
+
116
+ export function redactText(text) {
117
+ let out = String(text ?? "");
118
+ for (const secret of registeredSecrets) out = out.split(secret).join("***");
119
+ for (const { pattern, replacement } of SECRET_PATTERNS) out = out.replace(pattern, replacement);
120
+ return out;
121
+ }
package/lib/fsutil.js CHANGED
@@ -1,7 +1,9 @@
1
- import { rename, writeFile } from "node:fs/promises";
1
+ import { chmod, rename, writeFile } from "node:fs/promises";
2
2
 
3
- export async function writeFileAtomic(path, data) {
3
+ export async function writeFileAtomic(path, data, { mode } = {}) {
4
4
  const temp = `${path}.${process.pid}.tmp`;
5
- await writeFile(temp, data, "utf8");
5
+ await writeFile(temp, data, { encoding: "utf8", ...(mode ? { mode } : {}) });
6
6
  await rename(temp, path);
7
+ // Windows maps chmod to the read-only bit only; this is best-effort there.
8
+ if (mode) await chmod(path, mode).catch(() => {});
7
9
  }
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { permissionFlag } from "./node-permission.js";
3
3
 
4
- function run(command, args, timeout = 4000) {
4
+ // Shared CI runners can be slow to start a process; a 4s probe that times out
5
+ // under load turns into a false "engine unavailable" and flakes the suite.
6
+ function run(command, args, timeout = 10_000) {
5
7
  const result = spawnSync(command, args, { encoding: "utf8", timeout, windowsHide: true });
6
8
  return {
7
9
  ok: result.status === 0,
package/lib/usage.js ADDED
@@ -0,0 +1,321 @@
1
+ import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { configDir } from "./credentials.js";
5
+
6
+ // Events are redacted by construction: only counts, ids and ratios leave the
7
+ // process. Never add free-form text (inputs, descriptions, paths, errors).
8
+ const OPT_OUT_ENV = "MF_NO_USAGE";
9
+
10
+ let pendingDetails = null;
11
+
12
+ export function usageDir() {
13
+ return join(configDir(), "usage");
14
+ }
15
+
16
+ export function usageFile(date = new Date()) {
17
+ const month = date.toISOString().slice(0, 7);
18
+ return join(usageDir(), `${month}.jsonl`);
19
+ }
20
+
21
+ export function feedbackDir() {
22
+ return join(configDir(), "feedback");
23
+ }
24
+
25
+ export function defaultFeedbackPath(date = new Date()) {
26
+ const stamp = date.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
27
+ return join(feedbackDir(), `mf-feedback-${stamp}.json`);
28
+ }
29
+
30
+ export function noteUsage(details) {
31
+ pendingDetails = { ...(pendingDetails ?? {}), ...sanitizeDetails(details) };
32
+ }
33
+
34
+ export async function recordRun({ command, exitCode = 0, durationMs = 0, version }) {
35
+ if (process.env[OPT_OUT_ENV] === "1") {
36
+ pendingDetails = null;
37
+ return;
38
+ }
39
+ const record = buildRecord({ command, exitCode, durationMs, version });
40
+ try {
41
+ await mkdir(usageDir(), { recursive: true, mode: 0o700 });
42
+ await appendFile(usageFile(), `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
43
+ } catch {
44
+ // Usage logging must never fail a command.
45
+ } finally {
46
+ pendingDetails = null;
47
+ }
48
+ }
49
+
50
+ // The process "exit" hook is synchronous, so the CLI uses this variant.
51
+ export function recordRunSync({ command, exitCode = 0, durationMs = 0, version }) {
52
+ if (process.env[OPT_OUT_ENV] === "1") {
53
+ pendingDetails = null;
54
+ return;
55
+ }
56
+ const record = buildRecord({ command, exitCode, durationMs, version });
57
+ try {
58
+ mkdirSync(usageDir(), { recursive: true, mode: 0o700 });
59
+ appendFileSync(usageFile(), `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
60
+ } catch {
61
+ // ignore
62
+ } finally {
63
+ pendingDetails = null;
64
+ }
65
+ }
66
+
67
+ export function buildRecord({ command, exitCode, durationMs, version }) {
68
+ return {
69
+ schema: 1,
70
+ ts: new Date().toISOString(),
71
+ command: typeof command === "string" ? command.slice(0, 32) : "unknown",
72
+ exitCode: Number.isInteger(exitCode) ? exitCode : 1,
73
+ durationMs: Math.max(0, Math.round(Number(durationMs) || 0)),
74
+ mf: typeof version === "string" ? version : "unknown",
75
+ platform: process.platform,
76
+ arch: process.arch,
77
+ node: process.versions.node,
78
+ ...(pendingDetails ?? {})
79
+ };
80
+ }
81
+
82
+ export async function readUsageEvents() {
83
+ let files = [];
84
+ try {
85
+ files = (await readdir(usageDir())).filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).sort();
86
+ } catch (error) {
87
+ if (error.code === "ENOENT") return { events: [], files: [], skipped: 0 };
88
+ throw error;
89
+ }
90
+ const events = [];
91
+ let skipped = 0;
92
+ for (const file of files) {
93
+ const text = await readFile(join(usageDir(), file), "utf8");
94
+ for (const line of text.split(/\r?\n/)) {
95
+ if (!line.trim()) continue;
96
+ try {
97
+ const parsed = JSON.parse(line);
98
+ if (parsed && typeof parsed === "object" && typeof parsed.ts === "string") events.push(parsed);
99
+ else skipped += 1;
100
+ } catch {
101
+ skipped += 1;
102
+ }
103
+ }
104
+ }
105
+ events.sort((a, b) => a.ts.localeCompare(b.ts));
106
+ return { events, files, skipped };
107
+ }
108
+
109
+ export function summarizeUsage(events) {
110
+ const summary = {
111
+ runs: events.length,
112
+ firstRun: events[0]?.ts ?? null,
113
+ lastRun: events.at(-1)?.ts ?? null,
114
+ activeDays: new Set(events.map((event) => event.ts.slice(0, 10))).size,
115
+ exitCodes: {},
116
+ commands: {},
117
+ platforms: {},
118
+ nodeVersions: {},
119
+ mfVersions: {},
120
+ models: {},
121
+ eval: { runs: 0, executed: 0, meanSelection: null, meanPass: null, meanArgs: null, unstableCases: 0 },
122
+ lint: { runs: 0, errors: 0, warns: 0, infos: 0, rules: {} },
123
+ check: { runs: 0, verdicts: {} },
124
+ scan: { runs: 0, tools: 0, skills: 0 }
125
+ };
126
+ const selection = [];
127
+ const pass = [];
128
+ const args = [];
129
+
130
+ for (const event of events) {
131
+ bump(summary.exitCodes, String(event.exitCode ?? 0));
132
+ bump(summary.commands, event.command ?? "unknown");
133
+ if (event.platform) bump(summary.platforms, event.platform);
134
+ if (event.node) bump(summary.nodeVersions, event.node);
135
+ if (event.mf) bump(summary.mfVersions, event.mf);
136
+ for (const model of event.models ?? []) bump(summary.models, model);
137
+
138
+ if (event.command === "eval") {
139
+ summary.eval.runs += 1;
140
+ if (event.execEngine) summary.eval.executed += 1;
141
+ if (event.unstableCases) summary.eval.unstableCases += event.unstableCases;
142
+ if (typeof event.selectionAccuracy === "number") selection.push(event.selectionAccuracy);
143
+ if (typeof event.passRate === "number") pass.push(event.passRate);
144
+ if (typeof event.argAccuracy === "number") args.push(event.argAccuracy);
145
+ }
146
+ if (event.command === "lint") {
147
+ summary.lint.runs += 1;
148
+ summary.lint.errors += event.lintErrors ?? 0;
149
+ summary.lint.warns += event.lintWarns ?? 0;
150
+ summary.lint.infos += event.lintInfos ?? 0;
151
+ for (const [rule, count] of Object.entries(event.lintRules ?? {})) {
152
+ summary.lint.rules[rule] = (summary.lint.rules[rule] ?? 0) + count;
153
+ }
154
+ }
155
+ if (event.command === "check") {
156
+ summary.check.runs += 1;
157
+ bump(summary.check.verdicts, event.checkVerdict ?? "unknown");
158
+ }
159
+ if (event.command === "scan") {
160
+ summary.scan.runs += 1;
161
+ summary.scan.tools += event.scanTools ?? 0;
162
+ summary.scan.skills += event.scanSkills ?? 0;
163
+ }
164
+ }
165
+
166
+ summary.eval.meanSelection = mean(selection);
167
+ summary.eval.meanPass = mean(pass);
168
+ summary.eval.meanArgs = mean(args);
169
+ return summary;
170
+ }
171
+
172
+ export function buildFeedbackBundle({ events, version, generatedAt = new Date() }) {
173
+ return {
174
+ schema: 1,
175
+ kind: "modelfunction-feedback",
176
+ generatedAt: generatedAt.toISOString(),
177
+ mf: {
178
+ version,
179
+ node: process.versions.node,
180
+ platform: process.platform,
181
+ arch: process.arch
182
+ },
183
+ usage: summarizeUsage(events),
184
+ privacy:
185
+ "仅包含命令名、次数、模型 id、计数与比率。不含用例原文、工具描述、源码、文件路径或密钥。",
186
+ request:
187
+ "我们想了解:哪些命令被真实使用、在哪些平台上跑、评测通过率大概什么水平、lint/check 主要命中哪些规则。用于决定下一步做什么,不需要你的项目细节。"
188
+ };
189
+ }
190
+
191
+ export function formatStats(summary, { events = 0, files = 0, skipped = 0, path = usageDir() } = {}) {
192
+ if (summary.runs === 0) {
193
+ return [
194
+ `local usage: ${path}`,
195
+ "还没有本地记录。运行任意 mf 命令后会自动记录(仅计数,不上传)。",
196
+ "opt out: set MF_NO_USAGE=1"
197
+ ].join("\n");
198
+ }
199
+ const lines = [
200
+ `local usage: ${path} (${events} events, ${files} file(s)${skipped > 0 ? `, ${skipped} unreadable line(s)` : ""})`,
201
+ `runs: ${summary.runs} first: ${summary.firstRun} last: ${summary.lastRun} active days: ${summary.activeDays}`,
202
+ `exit codes: ${pairs(summary.exitCodes)}`,
203
+ `commands: ${pairs(summary.commands)}`
204
+ ];
205
+ if (Object.keys(summary.models).length > 0) lines.push(`models: ${pairs(summary.models)}`);
206
+ if (summary.eval.runs > 0) {
207
+ const parts = [`${summary.eval.runs} run(s)`];
208
+ if (summary.eval.meanSelection !== null) parts.push(`select ${percent(summary.eval.meanSelection)}`);
209
+ if (summary.eval.meanPass !== null) parts.push(`pass ${percent(summary.eval.meanPass)}`);
210
+ if (summary.eval.executed > 0) parts.push(`${summary.eval.executed} with execution`);
211
+ if (summary.eval.unstableCases > 0) parts.push(`${summary.eval.unstableCases} unstable case(s)`);
212
+ lines.push(`eval: ${parts.join(", ")}`);
213
+ }
214
+ if (summary.lint.runs > 0) {
215
+ const top = Object.entries(summary.lint.rules)
216
+ .sort((a, b) => b[1] - a[1])
217
+ .slice(0, 5)
218
+ .map(([rule, count]) => `${rule} ${count}`);
219
+ lines.push(
220
+ `lint: ${summary.lint.runs} run(s), error ${summary.lint.errors} / warn ${summary.lint.warns} / info ${summary.lint.infos}` +
221
+ (top.length > 0 ? `; top: ${top.join(", ")}` : "")
222
+ );
223
+ }
224
+ if (summary.check.runs > 0) lines.push(`check: ${summary.check.runs} run(s), ${pairs(summary.check.verdicts)}`);
225
+ if (summary.scan.runs > 0) lines.push(`scan: ${summary.scan.runs} run(s), ${summary.scan.tools} tools, ${summary.scan.skills} skills`);
226
+ lines.push(`versions: mf ${keys(summary.mfVersions)} node ${keys(summary.nodeVersions)} os ${keys(summary.platforms)}`);
227
+ lines.push(`no network, no telemetry. opt out: set MF_NO_USAGE=1. clear: delete ${path}`);
228
+ return lines.join("\n");
229
+ }
230
+
231
+ function bump(target, key) {
232
+ target[key] = (target[key] ?? 0) + 1;
233
+ }
234
+
235
+ function mean(values) {
236
+ if (values.length === 0) return null;
237
+ return Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10000) / 10000;
238
+ }
239
+
240
+ function percent(value) {
241
+ return `${(value * 100).toFixed(1)}%`;
242
+ }
243
+
244
+ function keys(counts) {
245
+ return Object.keys(counts).sort().slice(0, 5).join(", ") || "-";
246
+ }
247
+
248
+ function pairs(counts) {
249
+ return (
250
+ Object.entries(counts)
251
+ .sort((a, b) => b[1] - a[1])
252
+ .map(([key, value]) => `${key} ${value}`)
253
+ .join(", ") || "-"
254
+ );
255
+ }
256
+
257
+ const INT_FIELDS = new Set([
258
+ "caseCount",
259
+ "toolCount",
260
+ "reviewedCount",
261
+ "profileGroups",
262
+ "repeat",
263
+ "unstableCases",
264
+ "execExecuted",
265
+ "execPassed",
266
+ "execBlocked",
267
+ "execSkipped",
268
+ "scanTools",
269
+ "scanSkills",
270
+ "scanMcpServers",
271
+ "scanProbes",
272
+ "lintErrors",
273
+ "lintWarns",
274
+ "lintInfos",
275
+ "checkFindings",
276
+ "clusters"
277
+ ]);
278
+ const RATIO_FIELDS = new Set(["selectionAccuracy", "argAccuracy", "passRate"]);
279
+ const BOOL_FIELDS = new Set(["failedThreshold", "regressions"]);
280
+ const STRING_FIELDS = new Set(["execEngine", "checkVerdict"]);
281
+ const STRING_ARRAY_FIELDS = new Set(["models"]);
282
+ const COUNT_OBJECT_FIELDS = new Set(["lintRules"]);
283
+
284
+ export function sanitizeDetails(details) {
285
+ if (!details || typeof details !== "object") return {};
286
+ const out = {};
287
+ for (const [key, value] of Object.entries(details)) {
288
+ if (INT_FIELDS.has(key)) {
289
+ if (Number.isInteger(value) && value >= 0) out[key] = value;
290
+ continue;
291
+ }
292
+ if (RATIO_FIELDS.has(key)) {
293
+ if (typeof value === "number" && Number.isFinite(value)) out[key] = Math.round(value * 10000) / 10000;
294
+ continue;
295
+ }
296
+ if (BOOL_FIELDS.has(key)) {
297
+ if (typeof value === "boolean") out[key] = value;
298
+ continue;
299
+ }
300
+ if (STRING_FIELDS.has(key)) {
301
+ if (typeof value === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(value)) out[key] = value;
302
+ continue;
303
+ }
304
+ if (STRING_ARRAY_FIELDS.has(key)) {
305
+ if (Array.isArray(value)) {
306
+ out[key] = value.filter((item) => typeof item === "string" && item.length <= 64).slice(0, 20);
307
+ }
308
+ continue;
309
+ }
310
+ if (COUNT_OBJECT_FIELDS.has(key)) {
311
+ if (value && typeof value === "object" && !Array.isArray(value)) {
312
+ out[key] = Object.fromEntries(
313
+ Object.entries(value)
314
+ .filter(([rule, count]) => /^[a-z0-9-]{1,40}$/.test(rule) && Number.isInteger(count) && count >= 0)
315
+ .slice(0, 40)
316
+ );
317
+ }
318
+ }
319
+ }
320
+ return out;
321
+ }
package/mf.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from "node:fs/promises";
2
+ import { realpathSync } from "node:fs";
3
+ import { mkdir, readFile } from "node:fs/promises";
3
4
  import { basename, dirname, extname, relative, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { loadSuite, toModelTools } from "./lib/suite.js";
@@ -30,6 +31,25 @@ import {
30
31
  } from "./lib/profile.js";
31
32
  import { runExec } from "./lib/exec.js";
32
33
  import { writeFileAtomic } from "./lib/fsutil.js";
34
+ import {
35
+ applyModelCredentials,
36
+ credentialsPath,
37
+ listCredentials,
38
+ redactText,
39
+ registerSecrets,
40
+ removeCredential,
41
+ setCredential
42
+ } from "./lib/credentials.js";
43
+ import {
44
+ buildFeedbackBundle,
45
+ defaultFeedbackPath,
46
+ formatStats,
47
+ noteUsage,
48
+ readUsageEvents,
49
+ recordRunSync,
50
+ summarizeUsage,
51
+ usageDir
52
+ } from "./lib/usage.js";
33
53
  import { createMockAdapter } from "./lib/adapters/mock.js";
34
54
  import { createOpenAiAdapter } from "./lib/adapters/openai.js";
35
55
 
@@ -58,6 +78,12 @@ Usage:
58
78
  mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
59
79
  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]
60
80
  mf sandbox check [--json] # probe available isolation engines on this machine
81
+ mf config set-key <ENV_NAME> # store a model key in ~/.modelfunction/credentials.json (hidden input)
82
+ mf config remove-key <ENV_NAME> # delete a stored key
83
+ mf config list [--json] # names + masked values only
84
+ mf config path # print the credentials file path
85
+ mf stats [--json] # local usage summary (never uploaded)
86
+ mf feedback [--out <file>] [--json] # export a redacted feedback bundle; you decide whether to send it
61
87
  mf mcp # start stdio MCP server for coding agents
62
88
 
63
89
  Eval options:
@@ -87,6 +113,8 @@ Scan options:
87
113
  --no-write print only, do not write files
88
114
 
89
115
  Env keys: DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, ZHIPU_API_KEY, OPENAI_API_KEY, SILICONFLOW_API_KEY
116
+ Credentials: env vars win; missing model keys are read from ~/.modelfunction/credentials.json (MF_CONFIG_DIR overrides).
117
+ Usage: recorded locally under ~/.modelfunction/usage (counts only, never uploaded); MF_NO_USAGE=1 opts out.
90
118
 
91
119
  Examples:
92
120
  mf eval --models mock --show-cases
@@ -251,6 +279,35 @@ export async function runEval(options, io = console) {
251
279
  const failed = options.failUnder === null
252
280
  ? []
253
281
  : summaries.filter((summary) => summary.selectionAccuracy < options.failUnder);
282
+ const profileBelow =
283
+ profileReport && options.failUnder !== null
284
+ ? profileReport.models.flatMap((model) =>
285
+ model.profiles
286
+ .filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
287
+ .map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
288
+ )
289
+ : [];
290
+
291
+ noteUsage({
292
+ models: summaries.map((summary) => summary.model),
293
+ caseCount: cases.length,
294
+ toolCount: suite.tools.length,
295
+ reviewedCount: cases.filter((testCase) => testCase.reviewed !== false).length,
296
+ repeat: options.repeat ?? 1,
297
+ profileGroups: profileReport ? profileReport.groups.length : 0,
298
+ selectionAccuracy: average(summaries.map((summary) => summary.selectionAccuracy)),
299
+ argAccuracy: average(summaries.map((summary) => summary.argAccuracy)),
300
+ passRate: average(summaries.map((summary) => summary.passRate)),
301
+ unstableCases: summaries.reduce((total, summary) => total + (summary.stability?.unstable.length ?? 0), 0),
302
+ execEngine: executionInfo?.engine,
303
+ execExecuted: sumExec(summaries, "executed"),
304
+ execPassed: sumExec(summaries, "passed"),
305
+ execBlocked: sumExec(summaries, "blocked"),
306
+ execSkipped: sumExec(summaries, "skipped"),
307
+ failedThreshold: failed.length > 0 || profileBelow.length > 0,
308
+ regressions: regressions.length > 0
309
+ });
310
+
254
311
  if (failed.length > 0) {
255
312
  io.error(
256
313
  `below threshold ${pct(options.failUnder)}: ${failed
@@ -259,21 +316,24 @@ export async function runEval(options, io = console) {
259
316
  );
260
317
  return 2;
261
318
  }
262
- if (profileReport && options.failUnder !== null) {
263
- const below = profileReport.models.flatMap((model) =>
264
- model.profiles
265
- .filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
266
- .map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
267
- );
268
- if (below.length > 0) {
269
- io.error(`profile below threshold ${pct(options.failUnder)}: ${below.join(", ")}`);
270
- return 2;
271
- }
319
+ if (profileBelow.length > 0) {
320
+ io.error(`profile below threshold ${pct(options.failUnder)}: ${profileBelow.join(", ")}`);
321
+ return 2;
272
322
  }
273
323
  if (regressions.length > 0) return 2;
274
324
  return 0;
275
325
  }
276
326
 
327
+ function average(values) {
328
+ const usable = values.filter((value) => typeof value === "number" && Number.isFinite(value));
329
+ if (usable.length === 0) return undefined;
330
+ return usable.reduce((sum, value) => sum + value, 0) / usable.length;
331
+ }
332
+
333
+ function sumExec(summaries, field) {
334
+ return summaries.reduce((total, summary) => total + (summary.exec?.[field] ?? 0), 0);
335
+ }
336
+
277
337
  export async function runAblate(options, io = console) {
278
338
  const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
279
339
  if (!options.tool) throw new Error("--tool is required");
@@ -314,6 +374,12 @@ export async function runAblate(options, io = console) {
314
374
  models,
315
375
  ...(scope ? { profile: { name: scope.name, tools: baseTools.length } } : {})
316
376
  };
377
+ noteUsage({
378
+ models: models.map((model) => model.model),
379
+ caseCount: baseCases.length,
380
+ toolCount: baseTools.length,
381
+ profileGroups: scope ? 1 : 0
382
+ });
317
383
  io.log(options.json ? JSON.stringify(payload, null, 2) : formatAblation(payload));
318
384
  return 0;
319
385
  }
@@ -354,6 +420,13 @@ export async function runScan(options, io = console) {
354
420
  }
355
421
  }
356
422
 
423
+ noteUsage({
424
+ scanTools: report.tools.length,
425
+ scanSkills: report.skills.length,
426
+ scanMcpServers: report.mcpServers.length,
427
+ scanProbes: report.probes.length
428
+ });
429
+
357
430
  const draft = buildSuiteDraft(report);
358
431
 
359
432
  if (options.json) {
@@ -417,6 +490,7 @@ export async function runCoverage(options, io = console) {
417
490
  allowEmptyTools: true
418
491
  });
419
492
  const report = computeCoverage(suite);
493
+ noteUsage({ toolCount: suite.tools.length, caseCount: suite.cases.length });
420
494
  io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
421
495
  return 0;
422
496
  }
@@ -436,6 +510,7 @@ export async function runCheck(options, io = console) {
436
510
  ? JSON.parse(await readFile(resolve(options.installed), "utf8"))
437
511
  : [];
438
512
  const result = check({ manifest, installed });
513
+ noteUsage({ checkVerdict: result.verdict, checkFindings: result.findings.length });
439
514
  io.log(options.json ? JSON.stringify(result, null, 2) : formatCheck(result));
440
515
  return 0;
441
516
  }
@@ -446,6 +521,15 @@ export async function runLint(options, io = console) {
446
521
  allowEmptyTools: true
447
522
  });
448
523
  const report = lintSuite(suite);
524
+ const rules = {};
525
+ for (const finding of report.findings) rules[finding.code] = (rules[finding.code] ?? 0) + 1;
526
+ noteUsage({
527
+ toolCount: report.tools,
528
+ lintErrors: report.counts.error,
529
+ lintWarns: report.counts.warn,
530
+ lintInfos: report.counts.info,
531
+ lintRules: rules
532
+ });
449
533
  io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
450
534
  return report.counts.error > 0 ? 1 : 0;
451
535
  }
@@ -460,6 +544,123 @@ export async function runCompare(options, io = console) {
460
544
  return changed ? 1 : 0;
461
545
  }
462
546
 
547
+ export async function runConfig(options, io = console) {
548
+ const [sub, name] = options.args ?? [];
549
+ if (sub === "set-key") {
550
+ if (!name) throw new Error("usage: mf config set-key <ENV_NAME>");
551
+ const value = await (options.readSecret ?? readSecret)(`${name}: `);
552
+ const result = await setCredential(name, value);
553
+ io.log(`saved ${result.name} → ${result.path}`);
554
+ io.log("permissions: 0600 where supported. env vars still win; CI should use env or a secret manager.");
555
+ return 0;
556
+ }
557
+ if (sub === "remove-key") {
558
+ if (!name) throw new Error("usage: mf config remove-key <ENV_NAME>");
559
+ const result = await removeCredential(name);
560
+ io.log(
561
+ result.removed
562
+ ? `removed ${result.name} from ${result.path}`
563
+ : `${result.name} is not stored (${result.path})`
564
+ );
565
+ return 0;
566
+ }
567
+ if (sub === "list") {
568
+ const entries = await listCredentials();
569
+ if (options.json) {
570
+ io.log(JSON.stringify({ path: credentialsPath(), keys: entries }, null, 2));
571
+ return 0;
572
+ }
573
+ if (entries.length === 0) {
574
+ io.log(`no stored keys (${credentialsPath()})`);
575
+ return 0;
576
+ }
577
+ for (const entry of entries) io.log(`${entry.name} ${entry.masked}`);
578
+ io.log(`file: ${credentialsPath()}`);
579
+ return 0;
580
+ }
581
+ if (sub === "path") {
582
+ io.log(credentialsPath());
583
+ return 0;
584
+ }
585
+ throw new Error("usage: mf config <set-key <ENV_NAME> | remove-key <ENV_NAME> | list | path>");
586
+ }
587
+
588
+ export async function runStats(options, io = console) {
589
+ const { events, files, skipped } = await readUsageEvents();
590
+ const summary = summarizeUsage(events);
591
+ if (options.json) {
592
+ io.log(JSON.stringify({ path: usageDir(), files: files.length, skipped, ...summary }, null, 2));
593
+ } else {
594
+ io.log(formatStats(summary, { events: events.length, files: files.length, skipped, path: usageDir() }));
595
+ }
596
+ return 0;
597
+ }
598
+
599
+ export async function runFeedback(options, io = console) {
600
+ const { events } = await readUsageEvents();
601
+ const version = await readVersion();
602
+ const bundle = buildFeedbackBundle({ events, version });
603
+ const target = resolve(options.out ?? defaultFeedbackPath());
604
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
605
+ await writeFileAtomic(target, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 });
606
+ if (options.json) io.log(JSON.stringify(bundle, null, 2));
607
+ io.log(`wrote ${target}`);
608
+ io.log(bundle.privacy);
609
+ io.log("send it to hi@yunzerui.com if you want us to look(可选,不发送不影响使用)。");
610
+ return 0;
611
+ }
612
+
613
+ // Hidden input: a key must never appear in argv, shell history, or logs.
614
+ async function readSecret(prompt) {
615
+ const stdin = process.stdin;
616
+ if (!stdin.isTTY) {
617
+ const chunks = [];
618
+ for await (const chunk of stdin) chunks.push(chunk);
619
+ const value = Buffer.concat(chunks).toString("utf8").trim();
620
+ if (!value) throw new Error("empty input; nothing written");
621
+ return value;
622
+ }
623
+ process.stdout.write(prompt);
624
+ return new Promise((resolvePromise, reject) => {
625
+ let value = "";
626
+ const cleanup = () => {
627
+ stdin.removeListener("data", onData);
628
+ stdin.setRawMode(false);
629
+ stdin.pause();
630
+ };
631
+ const onData = (chunk) => {
632
+ for (const char of String(chunk)) {
633
+ if (char === "\u0003") {
634
+ cleanup();
635
+ process.stdout.write("\n");
636
+ reject(new Error("cancelled"));
637
+ return;
638
+ }
639
+ if (char === "\r" || char === "\n") {
640
+ cleanup();
641
+ process.stdout.write("\n");
642
+ resolvePromise(value.trim());
643
+ return;
644
+ }
645
+ if (char === "\u007f" || char === "\b") {
646
+ value = value.slice(0, -1);
647
+ continue;
648
+ }
649
+ value += char;
650
+ }
651
+ };
652
+ stdin.setRawMode(true);
653
+ stdin.setEncoding("utf8");
654
+ stdin.resume();
655
+ stdin.on("data", onData);
656
+ });
657
+ }
658
+
659
+ async function readVersion() {
660
+ const pkg = JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8"));
661
+ return pkg.version;
662
+ }
663
+
463
664
  async function readStdin() {
464
665
  const chunks = [];
465
666
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -476,6 +677,8 @@ function selectCases(cases, ids) {
476
677
 
477
678
  async function resolveModels(ids, modelsFile, io) {
478
679
  const registry = JSON.parse(await readFile(resolve(modelsFile), "utf8"));
680
+ // Env wins; the credentials file only fills what is missing.
681
+ await applyModelCredentials(registry);
479
682
  const explicit = Boolean(ids && ids.length > 0);
480
683
  const chosen = explicit ? ids : autoSelect(registry);
481
684
  const adapters = [];
@@ -492,11 +695,13 @@ async function resolveModels(ids, modelsFile, io) {
492
695
  if (config.type !== "openai") {
493
696
  throw new Error(`model "${id}": unsupported type "${config.type}"`);
494
697
  }
495
- if (!process.env[config.apiKeyEnv]) {
698
+ const apiKey = process.env[config.apiKeyEnv];
699
+ if (!apiKey) {
496
700
  if (explicit) throw new Error(`model "${id}": environment variable ${config.apiKeyEnv} is not set`);
497
701
  io.error(`skip ${id}: ${config.apiKeyEnv} is not set`);
498
702
  continue;
499
703
  }
704
+ registerSecrets([apiKey]);
500
705
  adapters.push({ id, ...createOpenAiAdapter({ id, ...config }) });
501
706
  }
502
707
 
@@ -643,6 +848,7 @@ async function evaluateProfiles({ suite, cases, runs, registry, selection, repea
643
848
  export async function runCluster(options, io = console) {
644
849
  const suite = await loadSuite(options.suite, { allowEmptyCases: true });
645
850
  const result = clusterTools(suite.tools, { threshold: options.threshold, maxSize: options.maxSize });
851
+ noteUsage({ toolCount: suite.tools.length, clusters: result.clusters.length });
646
852
  if (options.out) {
647
853
  const target = resolve(options.out);
648
854
  const profiles = Object.fromEntries(result.clusters.map((cluster) => [cluster.label, cluster.tools]));
@@ -795,26 +1001,51 @@ function splitList(value) {
795
1001
  return value.split(",").map((item) => item.trim()).filter(Boolean);
796
1002
  }
797
1003
 
798
- const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1004
+ // pnpm installs packages through symlinks; Node resolves ESM to the real
1005
+ // path, so a plain resolve(argv[1]) mismatch makes the CLI exit silently.
1006
+ const isMain = (() => {
1007
+ if (!process.argv[1]) return false;
1008
+ try {
1009
+ return realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url));
1010
+ } catch {
1011
+ return false;
1012
+ }
1013
+ })();
799
1014
 
800
1015
  if (isMain) {
801
1016
  let argv = process.argv.slice(2);
802
1017
  // `npx modelfunction-cli mf scan` passes the bin name through; accept it.
803
1018
  if (argv[0] === "mf") argv = argv.slice(1);
1019
+
1020
+ const version = await readVersion();
1021
+ const startedAt = Date.now();
1022
+ let commandName = null;
1023
+ const skipUsage = new Set(["mcp", "config", "stats", "feedback"]);
1024
+ // Usage is local and count-only; the exit hook keeps every branch covered.
1025
+ process.on("exit", (code) => {
1026
+ if (!commandName || skipUsage.has(commandName)) return;
1027
+ recordRunSync({
1028
+ command: commandName,
1029
+ exitCode: typeof code === "number" ? code : 0,
1030
+ durationMs: Date.now() - startedAt,
1031
+ version
1032
+ });
1033
+ });
1034
+
804
1035
  if (argv[0] === "--version" || argv[0] === "-v") {
805
- const pkg = JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8"));
806
- process.stdout.write(`mf ${pkg.version}\n`);
1036
+ process.stdout.write(`mf ${version}\n`);
807
1037
  process.exit(0);
808
1038
  } else if (argv[0] === "sandbox") {
809
1039
  if (argv[1] !== "check") {
810
1040
  process.stderr.write("usage: mf sandbox check [--json]\n");
811
1041
  process.exit(1);
812
1042
  }
1043
+ commandName = "sandbox";
813
1044
  try {
814
1045
  const code = await runSandboxCheck({ json: argv.includes("--json") });
815
1046
  process.exit(code);
816
1047
  } catch (error) {
817
- process.stderr.write(`${error?.message ?? error}\n`);
1048
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
818
1049
  process.exit(1);
819
1050
  }
820
1051
  } else if (argv[0] === "compare") {
@@ -823,11 +1054,44 @@ if (isMain) {
823
1054
  process.stderr.write("usage: mf compare <before.json> <after.json> [--json]\n");
824
1055
  process.exit(1);
825
1056
  }
1057
+ commandName = "compare";
826
1058
  try {
827
1059
  const code = await runCompare({ paths, json: argv.includes("--json") });
828
1060
  process.exit(code);
829
1061
  } catch (error) {
830
- process.stderr.write(`${error?.message ?? error}\n`);
1062
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
1063
+ process.exit(1);
1064
+ }
1065
+ } else if (argv[0] === "config") {
1066
+ try {
1067
+ const code = await runConfig({ args: argv.slice(1), json: argv.includes("--json") });
1068
+ process.exit(code);
1069
+ } catch (error) {
1070
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
1071
+ process.exit(1);
1072
+ }
1073
+ } else if (argv[0] === "stats") {
1074
+ try {
1075
+ const code = await runStats({ json: argv.includes("--json") });
1076
+ process.exit(code);
1077
+ } catch (error) {
1078
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
1079
+ process.exit(1);
1080
+ }
1081
+ } else if (argv[0] === "feedback") {
1082
+ try {
1083
+ const outIndex = argv.indexOf("--out");
1084
+ if (outIndex >= 0 && !argv[outIndex + 1]) {
1085
+ process.stderr.write("usage: mf feedback [--out <file>] [--json]\n");
1086
+ process.exit(1);
1087
+ }
1088
+ const code = await runFeedback({
1089
+ out: outIndex >= 0 ? argv[outIndex + 1] : null,
1090
+ json: argv.includes("--json")
1091
+ });
1092
+ process.exit(code);
1093
+ } catch (error) {
1094
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
831
1095
  process.exit(1);
832
1096
  }
833
1097
  } else if (argv[0] === "mcp") {
@@ -835,7 +1099,7 @@ if (isMain) {
835
1099
  const { startMcpServer } = await import("./lib/mcp-server.js");
836
1100
  await startMcpServer();
837
1101
  } catch (error) {
838
- process.stderr.write(`${error?.message ?? error}\n`);
1102
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
839
1103
  process.exit(1);
840
1104
  }
841
1105
  } else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
@@ -854,6 +1118,7 @@ if (isMain) {
854
1118
  process.stdout.write(USAGE);
855
1119
  process.exit(0);
856
1120
  }
1121
+ commandName = argv[0] === "case" ? "case" : argv[0];
857
1122
  const code =
858
1123
  argv[0] === "scan"
859
1124
  ? await runScan(options)
@@ -874,7 +1139,7 @@ if (isMain) {
874
1139
  : await runEval(options);
875
1140
  process.exit(code);
876
1141
  } catch (error) {
877
- process.stderr.write(`${error?.message ?? error}\n`);
1142
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
878
1143
  process.exit(1);
879
1144
  }
880
1145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",