modelfunction-cli 0.4.4 → 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 +29 -1
- package/SCHEMA.md +63 -1
- package/lib/credentials.js +121 -0
- package/lib/fsutil.js +5 -3
- package/lib/sandbox-check.js +3 -1
- package/lib/usage.js +321 -0
- package/mf.js +273 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
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` 看稳定性
|
|
@@ -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`)不计入正式分数。
|
package/SCHEMA.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Schemas v1
|
|
2
2
|
|
|
3
|
-
|
|
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
|
}
|
package/lib/sandbox-check.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { permissionFlag } from "./node-permission.js";
|
|
3
3
|
|
|
4
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { loadSuite, toModelTools } from "./lib/suite.js";
|
|
@@ -31,6 +31,25 @@ import {
|
|
|
31
31
|
} from "./lib/profile.js";
|
|
32
32
|
import { runExec } from "./lib/exec.js";
|
|
33
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";
|
|
34
53
|
import { createMockAdapter } from "./lib/adapters/mock.js";
|
|
35
54
|
import { createOpenAiAdapter } from "./lib/adapters/openai.js";
|
|
36
55
|
|
|
@@ -59,6 +78,12 @@ Usage:
|
|
|
59
78
|
mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
|
|
60
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]
|
|
61
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
|
|
62
87
|
mf mcp # start stdio MCP server for coding agents
|
|
63
88
|
|
|
64
89
|
Eval options:
|
|
@@ -88,6 +113,8 @@ Scan options:
|
|
|
88
113
|
--no-write print only, do not write files
|
|
89
114
|
|
|
90
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.
|
|
91
118
|
|
|
92
119
|
Examples:
|
|
93
120
|
mf eval --models mock --show-cases
|
|
@@ -252,6 +279,35 @@ export async function runEval(options, io = console) {
|
|
|
252
279
|
const failed = options.failUnder === null
|
|
253
280
|
? []
|
|
254
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
|
+
|
|
255
311
|
if (failed.length > 0) {
|
|
256
312
|
io.error(
|
|
257
313
|
`below threshold ${pct(options.failUnder)}: ${failed
|
|
@@ -260,21 +316,24 @@ export async function runEval(options, io = console) {
|
|
|
260
316
|
);
|
|
261
317
|
return 2;
|
|
262
318
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
|
|
267
|
-
.map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
|
|
268
|
-
);
|
|
269
|
-
if (below.length > 0) {
|
|
270
|
-
io.error(`profile below threshold ${pct(options.failUnder)}: ${below.join(", ")}`);
|
|
271
|
-
return 2;
|
|
272
|
-
}
|
|
319
|
+
if (profileBelow.length > 0) {
|
|
320
|
+
io.error(`profile below threshold ${pct(options.failUnder)}: ${profileBelow.join(", ")}`);
|
|
321
|
+
return 2;
|
|
273
322
|
}
|
|
274
323
|
if (regressions.length > 0) return 2;
|
|
275
324
|
return 0;
|
|
276
325
|
}
|
|
277
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
|
+
|
|
278
337
|
export async function runAblate(options, io = console) {
|
|
279
338
|
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
|
|
280
339
|
if (!options.tool) throw new Error("--tool is required");
|
|
@@ -315,6 +374,12 @@ export async function runAblate(options, io = console) {
|
|
|
315
374
|
models,
|
|
316
375
|
...(scope ? { profile: { name: scope.name, tools: baseTools.length } } : {})
|
|
317
376
|
};
|
|
377
|
+
noteUsage({
|
|
378
|
+
models: models.map((model) => model.model),
|
|
379
|
+
caseCount: baseCases.length,
|
|
380
|
+
toolCount: baseTools.length,
|
|
381
|
+
profileGroups: scope ? 1 : 0
|
|
382
|
+
});
|
|
318
383
|
io.log(options.json ? JSON.stringify(payload, null, 2) : formatAblation(payload));
|
|
319
384
|
return 0;
|
|
320
385
|
}
|
|
@@ -355,6 +420,13 @@ export async function runScan(options, io = console) {
|
|
|
355
420
|
}
|
|
356
421
|
}
|
|
357
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
|
+
|
|
358
430
|
const draft = buildSuiteDraft(report);
|
|
359
431
|
|
|
360
432
|
if (options.json) {
|
|
@@ -418,6 +490,7 @@ export async function runCoverage(options, io = console) {
|
|
|
418
490
|
allowEmptyTools: true
|
|
419
491
|
});
|
|
420
492
|
const report = computeCoverage(suite);
|
|
493
|
+
noteUsage({ toolCount: suite.tools.length, caseCount: suite.cases.length });
|
|
421
494
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
|
|
422
495
|
return 0;
|
|
423
496
|
}
|
|
@@ -437,6 +510,7 @@ export async function runCheck(options, io = console) {
|
|
|
437
510
|
? JSON.parse(await readFile(resolve(options.installed), "utf8"))
|
|
438
511
|
: [];
|
|
439
512
|
const result = check({ manifest, installed });
|
|
513
|
+
noteUsage({ checkVerdict: result.verdict, checkFindings: result.findings.length });
|
|
440
514
|
io.log(options.json ? JSON.stringify(result, null, 2) : formatCheck(result));
|
|
441
515
|
return 0;
|
|
442
516
|
}
|
|
@@ -447,6 +521,15 @@ export async function runLint(options, io = console) {
|
|
|
447
521
|
allowEmptyTools: true
|
|
448
522
|
});
|
|
449
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
|
+
});
|
|
450
533
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
|
|
451
534
|
return report.counts.error > 0 ? 1 : 0;
|
|
452
535
|
}
|
|
@@ -461,6 +544,123 @@ export async function runCompare(options, io = console) {
|
|
|
461
544
|
return changed ? 1 : 0;
|
|
462
545
|
}
|
|
463
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
|
+
|
|
464
664
|
async function readStdin() {
|
|
465
665
|
const chunks = [];
|
|
466
666
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
@@ -477,6 +677,8 @@ function selectCases(cases, ids) {
|
|
|
477
677
|
|
|
478
678
|
async function resolveModels(ids, modelsFile, io) {
|
|
479
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);
|
|
480
682
|
const explicit = Boolean(ids && ids.length > 0);
|
|
481
683
|
const chosen = explicit ? ids : autoSelect(registry);
|
|
482
684
|
const adapters = [];
|
|
@@ -493,11 +695,13 @@ async function resolveModels(ids, modelsFile, io) {
|
|
|
493
695
|
if (config.type !== "openai") {
|
|
494
696
|
throw new Error(`model "${id}": unsupported type "${config.type}"`);
|
|
495
697
|
}
|
|
496
|
-
|
|
698
|
+
const apiKey = process.env[config.apiKeyEnv];
|
|
699
|
+
if (!apiKey) {
|
|
497
700
|
if (explicit) throw new Error(`model "${id}": environment variable ${config.apiKeyEnv} is not set`);
|
|
498
701
|
io.error(`skip ${id}: ${config.apiKeyEnv} is not set`);
|
|
499
702
|
continue;
|
|
500
703
|
}
|
|
704
|
+
registerSecrets([apiKey]);
|
|
501
705
|
adapters.push({ id, ...createOpenAiAdapter({ id, ...config }) });
|
|
502
706
|
}
|
|
503
707
|
|
|
@@ -644,6 +848,7 @@ async function evaluateProfiles({ suite, cases, runs, registry, selection, repea
|
|
|
644
848
|
export async function runCluster(options, io = console) {
|
|
645
849
|
const suite = await loadSuite(options.suite, { allowEmptyCases: true });
|
|
646
850
|
const result = clusterTools(suite.tools, { threshold: options.threshold, maxSize: options.maxSize });
|
|
851
|
+
noteUsage({ toolCount: suite.tools.length, clusters: result.clusters.length });
|
|
647
852
|
if (options.out) {
|
|
648
853
|
const target = resolve(options.out);
|
|
649
854
|
const profiles = Object.fromEntries(result.clusters.map((cluster) => [cluster.label, cluster.tools]));
|
|
@@ -811,20 +1016,36 @@ if (isMain) {
|
|
|
811
1016
|
let argv = process.argv.slice(2);
|
|
812
1017
|
// `npx modelfunction-cli mf scan` passes the bin name through; accept it.
|
|
813
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
|
+
|
|
814
1035
|
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
815
|
-
|
|
816
|
-
process.stdout.write(`mf ${pkg.version}\n`);
|
|
1036
|
+
process.stdout.write(`mf ${version}\n`);
|
|
817
1037
|
process.exit(0);
|
|
818
1038
|
} else if (argv[0] === "sandbox") {
|
|
819
1039
|
if (argv[1] !== "check") {
|
|
820
1040
|
process.stderr.write("usage: mf sandbox check [--json]\n");
|
|
821
1041
|
process.exit(1);
|
|
822
1042
|
}
|
|
1043
|
+
commandName = "sandbox";
|
|
823
1044
|
try {
|
|
824
1045
|
const code = await runSandboxCheck({ json: argv.includes("--json") });
|
|
825
1046
|
process.exit(code);
|
|
826
1047
|
} catch (error) {
|
|
827
|
-
process.stderr.write(`${error?.message ?? error}\n`);
|
|
1048
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
828
1049
|
process.exit(1);
|
|
829
1050
|
}
|
|
830
1051
|
} else if (argv[0] === "compare") {
|
|
@@ -833,11 +1054,44 @@ if (isMain) {
|
|
|
833
1054
|
process.stderr.write("usage: mf compare <before.json> <after.json> [--json]\n");
|
|
834
1055
|
process.exit(1);
|
|
835
1056
|
}
|
|
1057
|
+
commandName = "compare";
|
|
836
1058
|
try {
|
|
837
1059
|
const code = await runCompare({ paths, json: argv.includes("--json") });
|
|
838
1060
|
process.exit(code);
|
|
839
1061
|
} catch (error) {
|
|
840
|
-
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`);
|
|
841
1095
|
process.exit(1);
|
|
842
1096
|
}
|
|
843
1097
|
} else if (argv[0] === "mcp") {
|
|
@@ -845,7 +1099,7 @@ if (isMain) {
|
|
|
845
1099
|
const { startMcpServer } = await import("./lib/mcp-server.js");
|
|
846
1100
|
await startMcpServer();
|
|
847
1101
|
} catch (error) {
|
|
848
|
-
process.stderr.write(`${error?.message ?? error}\n`);
|
|
1102
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
849
1103
|
process.exit(1);
|
|
850
1104
|
}
|
|
851
1105
|
} else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
|
|
@@ -864,6 +1118,7 @@ if (isMain) {
|
|
|
864
1118
|
process.stdout.write(USAGE);
|
|
865
1119
|
process.exit(0);
|
|
866
1120
|
}
|
|
1121
|
+
commandName = argv[0] === "case" ? "case" : argv[0];
|
|
867
1122
|
const code =
|
|
868
1123
|
argv[0] === "scan"
|
|
869
1124
|
? await runScan(options)
|
|
@@ -884,7 +1139,7 @@ if (isMain) {
|
|
|
884
1139
|
: await runEval(options);
|
|
885
1140
|
process.exit(code);
|
|
886
1141
|
} catch (error) {
|
|
887
|
-
process.stderr.write(`${error?.message ?? error}\n`);
|
|
1142
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
888
1143
|
process.exit(1);
|
|
889
1144
|
}
|
|
890
1145
|
}
|