bfi-cli 0.1.98 → 0.1.100
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 +117 -5
- package/dist/THIRD-PARTY-LICENSES.md.gz +0 -0
- package/dist/_chunks/agents.mjs +1 -1
- package/dist/_chunks/agents2.mjs +4 -1
- package/dist/_chunks/client.mjs +107 -0
- package/dist/_chunks/command.mjs +698 -0
- package/dist/_chunks/digest.mjs +132 -0
- package/dist/_chunks/files.mjs +89 -0
- package/dist/_chunks/{registry-client.mjs → intent.mjs} +163 -13
- package/dist/_chunks/libs/@clack/core.mjs +63 -58
- package/dist/_chunks/libs/@clack/prompts.mjs +46 -45
- package/dist/_chunks/libs/@kwsites/file-exists.mjs +10 -9
- package/dist/_chunks/libs/simple-git.mjs +4 -2
- package/dist/_chunks/manager.mjs +436 -0
- package/dist/_chunks/mcp.mjs +559 -0
- package/dist/_chunks/model.mjs +124 -0
- package/dist/_chunks/ownership.mjs +7 -0
- package/dist/_chunks/package-plugin.mjs +473 -36
- package/dist/_chunks/receipt.mjs +166 -0
- package/dist/_chunks/rolldown-runtime.mjs +1 -1
- package/dist/_chunks/skills.mjs +6 -3
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +319 -233
- package/dist/mcp-codesearch/main.d.mts +1 -1
- package/dist/mcp-db/main.d.mts +1 -1
- package/dist/mcp-weknora/main.d.mts +1 -1
- package/package.json +4 -2
- package/dist/THIRD-PARTY-LICENSES.md +0 -293
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { builtinCommands, entriesFromJson, normalizeConfig, object, resolveVariables, validName, variables } from "./model.mjs";
|
|
2
|
+
import { agentIds, applyPlan, decodeServer, loadTarget, planInstall, planRemove, readDocument, targetFor } from "./manager.mjs";
|
|
3
|
+
import { readConfig } from "./files.mjs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
11
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
12
|
+
async function readCatalog(registry, query = "", fetcher = fetch) {
|
|
13
|
+
let base;
|
|
14
|
+
try {
|
|
15
|
+
base = new URL(registry);
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error("SkillHub 地址无效。");
|
|
18
|
+
}
|
|
19
|
+
if (!["https:", "http:"].includes(base.protocol) || base.username || base.password || base.search || base.hash) throw new Error("SkillHub 地址必须为无凭据的 HTTP(S) 地址。");
|
|
20
|
+
const entries = [];
|
|
21
|
+
const seen = /* @__PURE__ */ new Set();
|
|
22
|
+
for (let page = 0; page < 100; page++) {
|
|
23
|
+
const url = new URL(`${base.toString().replace(/\/$/, "")}/api/web/mcps`);
|
|
24
|
+
url.search = new URLSearchParams({
|
|
25
|
+
query,
|
|
26
|
+
page: String(page),
|
|
27
|
+
size: "100"
|
|
28
|
+
}).toString();
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timeout = setTimeout(() => controller.abort(), 15e3);
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetcher(url, {
|
|
33
|
+
signal: controller.signal,
|
|
34
|
+
redirect: "error",
|
|
35
|
+
headers: { Accept: "application/json" }
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) throw new Error();
|
|
38
|
+
const reader = response.body?.getReader();
|
|
39
|
+
if (!reader) throw new Error();
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
try {
|
|
43
|
+
while (true) {
|
|
44
|
+
const chunk = await reader.read();
|
|
45
|
+
if (chunk.done) break;
|
|
46
|
+
size += chunk.value.byteLength;
|
|
47
|
+
if (size > 2097152) throw new Error();
|
|
48
|
+
chunks.push(chunk.value);
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
await reader.cancel().catch(() => {});
|
|
52
|
+
}
|
|
53
|
+
const envelope = object(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
54
|
+
if (envelope.code !== 0) throw new Error();
|
|
55
|
+
const data = object(envelope.data);
|
|
56
|
+
if (!Array.isArray(data.items) || typeof data.total !== "number" || data.total < 0) throw new Error();
|
|
57
|
+
for (const item of data.items) {
|
|
58
|
+
const row = object(item);
|
|
59
|
+
if (typeof row.registrationName !== "string") throw new Error();
|
|
60
|
+
const name = validName(row.registrationName);
|
|
61
|
+
if (seen.has(name)) throw new Error();
|
|
62
|
+
seen.add(name);
|
|
63
|
+
entries.push({
|
|
64
|
+
registrationName: name,
|
|
65
|
+
id: typeof row.id === "string" ? row.id : void 0,
|
|
66
|
+
title: typeof row.title === "string" ? row.title : void 0,
|
|
67
|
+
description: typeof row.description === "string" ? row.description : void 0,
|
|
68
|
+
revision: typeof row.revision === "number" ? row.revision : void 0,
|
|
69
|
+
config: normalizeConfig(row.config),
|
|
70
|
+
runtime: row.runtime ? { source: String(object(row.runtime).source) } : null
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (entries.length >= data.total) return entries;
|
|
74
|
+
if (!data.items.length) throw new Error();
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error("读取 MCP 市场失败:请核对 SkillHub 地址、网络及服务版本;未修改本地配置。");
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw new Error("MCP 市场分页超过安全上限,请缩小查询范围。");
|
|
82
|
+
}
|
|
83
|
+
async function catalogEntries(registry, names) {
|
|
84
|
+
const entries = [];
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
validName(name);
|
|
87
|
+
const matches = (await readCatalog(registry, name)).filter((entry) => entry.registrationName === name);
|
|
88
|
+
if (matches.length !== 1) throw new Error(`市场中未找到唯一 MCP:${name}。请用 bfi mcp search 查询准确名称。`);
|
|
89
|
+
entries.push(matches[0]);
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
async function probe(config, options = {}) {
|
|
94
|
+
const environment = options.environment ?? process.env;
|
|
95
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
96
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 100 || timeoutMs > 3e5) throw new Error("测试超时必须为 0.1–300 秒。");
|
|
97
|
+
let resolved;
|
|
98
|
+
try {
|
|
99
|
+
resolved = resolveVariables(config, {}, environment);
|
|
100
|
+
} catch {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
phase: "configuration",
|
|
104
|
+
code: "INVALID_INPUT",
|
|
105
|
+
message: "所需环境变量缺失或值无效,请核对安装计划列出的变量;不会保存或回显凭据。"
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const client = new Client({
|
|
109
|
+
name: "bfi-mcp-check",
|
|
110
|
+
version: "1.0.0"
|
|
111
|
+
}, { capabilities: {} });
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
let phase = "connect";
|
|
114
|
+
let transport;
|
|
115
|
+
const abort = () => controller.abort();
|
|
116
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
117
|
+
if (options.signal?.aborted) controller.abort();
|
|
118
|
+
const timeout = setTimeout(abort, timeoutMs);
|
|
119
|
+
if (resolved.type === "stdio") {
|
|
120
|
+
const child = new StdioClientTransport({
|
|
121
|
+
command: resolved.command,
|
|
122
|
+
args: resolved.args,
|
|
123
|
+
cwd: resolved.cwd,
|
|
124
|
+
env: {
|
|
125
|
+
...Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== void 0)),
|
|
126
|
+
...resolved.env
|
|
127
|
+
},
|
|
128
|
+
stderr: "pipe"
|
|
129
|
+
});
|
|
130
|
+
child.stderr?.on("data", () => {});
|
|
131
|
+
transport = child;
|
|
132
|
+
} else {
|
|
133
|
+
const destination = new URL(resolved.url);
|
|
134
|
+
const boundedFetch = async (input, init) => {
|
|
135
|
+
if (new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin) throw new Error("CROSS_ORIGIN");
|
|
136
|
+
const headers = new Headers(init?.headers);
|
|
137
|
+
for (const [name, value] of Object.entries(resolved.headers ?? {})) if (!headers.has(name)) headers.set(name, value);
|
|
138
|
+
return fetch(input, {
|
|
139
|
+
...init,
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
redirect: "error",
|
|
142
|
+
headers
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
const transportOptions = {
|
|
146
|
+
requestInit: {
|
|
147
|
+
headers: resolved.headers,
|
|
148
|
+
redirect: "error"
|
|
149
|
+
},
|
|
150
|
+
fetch: boundedFetch
|
|
151
|
+
};
|
|
152
|
+
transport = resolved.type === "sse" ? new SSEClientTransport(destination, transportOptions) : new StreamableHTTPClientTransport(destination, transportOptions);
|
|
153
|
+
}
|
|
154
|
+
let abortListener;
|
|
155
|
+
try {
|
|
156
|
+
const work = async () => {
|
|
157
|
+
try {
|
|
158
|
+
await client.connect(transport);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const code = error.code;
|
|
161
|
+
if (!options.autoSse || resolved.type !== "http" || ![404, 405].includes(code ?? 0)) throw error;
|
|
162
|
+
await transport.close();
|
|
163
|
+
return probe({
|
|
164
|
+
...config,
|
|
165
|
+
type: "sse"
|
|
166
|
+
}, {
|
|
167
|
+
...options,
|
|
168
|
+
autoSse: false,
|
|
169
|
+
signal: controller.signal
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
phase = "tools/list";
|
|
173
|
+
if (!client.getServerCapabilities()?.tools) return {
|
|
174
|
+
ok: true,
|
|
175
|
+
phase,
|
|
176
|
+
tools: []
|
|
177
|
+
};
|
|
178
|
+
const names = /* @__PURE__ */ new Set();
|
|
179
|
+
const cursors = /* @__PURE__ */ new Set();
|
|
180
|
+
let cursor;
|
|
181
|
+
for (let page = 0; page < 100; page++) {
|
|
182
|
+
const result = await client.listTools(cursor ? { cursor } : void 0, {
|
|
183
|
+
signal: controller.signal,
|
|
184
|
+
timeout: timeoutMs
|
|
185
|
+
});
|
|
186
|
+
for (const tool of result.tools) {
|
|
187
|
+
if (names.has(tool.name)) throw new Error("DUPLICATE_TOOL");
|
|
188
|
+
names.add(tool.name);
|
|
189
|
+
}
|
|
190
|
+
if (names.size > 5e3) throw new Error("TOO_MANY_TOOLS");
|
|
191
|
+
cursor = result.nextCursor;
|
|
192
|
+
if (!cursor) return {
|
|
193
|
+
ok: true,
|
|
194
|
+
phase,
|
|
195
|
+
tools: [...names]
|
|
196
|
+
};
|
|
197
|
+
if (cursors.has(cursor)) throw new Error("REPEATED_CURSOR");
|
|
198
|
+
cursors.add(cursor);
|
|
199
|
+
}
|
|
200
|
+
throw new Error("TOO_MANY_PAGES");
|
|
201
|
+
};
|
|
202
|
+
return await Promise.race([work(), new Promise((_resolve, reject) => {
|
|
203
|
+
abortListener = () => reject(/* @__PURE__ */ new Error("ABORTED"));
|
|
204
|
+
controller.signal.addEventListener("abort", abortListener, { once: true });
|
|
205
|
+
if (controller.signal.aborted) abortListener();
|
|
206
|
+
})]);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
const code = error.code;
|
|
209
|
+
const message = error instanceof Error ? error.message : "";
|
|
210
|
+
const timedOut = controller.signal.aborted;
|
|
211
|
+
const auth = code === 401 || code === 403 || /unauthorized|forbidden|\b401\b|\b403\b/i.test(message);
|
|
212
|
+
const missing = code === "ENOENT";
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
phase,
|
|
216
|
+
code: timedOut ? "ABORTED" : auth ? "AUTH_REQUIRED" : missing ? "COMMAND_NOT_FOUND" : "PROTOCOL_OR_CONNECTION_ERROR",
|
|
217
|
+
message: timedOut ? "测试超时或已取消。" : auth ? "认证失败,请核对环境变量、账号权限,或在目标 Agent 中完成 OAuth 登录。" : missing ? "启动命令不存在,请先安装相应的 Node.js/Python 运行环境及 MCP 程序。" : "连接或协议响应失败,请核对服务、登录态及配置;原始输出已隐藏,避免泄露凭据。"
|
|
218
|
+
};
|
|
219
|
+
} finally {
|
|
220
|
+
clearTimeout(timeout);
|
|
221
|
+
options.signal?.removeEventListener("abort", abort);
|
|
222
|
+
if (abortListener) controller.signal.removeEventListener("abort", abortListener);
|
|
223
|
+
controller.abort();
|
|
224
|
+
await client.close().catch(() => {});
|
|
225
|
+
await transport.close().catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const help = `bfi mcp — 安装、管理与验证独立 MCP(不影响技能或插件)
|
|
229
|
+
|
|
230
|
+
search [query] 搜索 SkillHub MCP 市场
|
|
231
|
+
info <name> 查看元信息、传输和所需变量(不显示凭据)
|
|
232
|
+
agents 查看支持的 Agent 与配置路径
|
|
233
|
+
install <name...> --agent <agent> 安装选中的 MCP,默认用户级
|
|
234
|
+
install --file <json> --agent <agent> 离线导入 mcpServers JSON
|
|
235
|
+
list --agent <agent> 列出所选文件中的 MCP
|
|
236
|
+
remove <name...> --agent <agent> 仅移除配置,不卸载程序
|
|
237
|
+
test <name...> --agent <agent> 显式启动/连接并查询 tools/list,不调用业务工具
|
|
238
|
+
|
|
239
|
+
--scope user|project 用户级(默认)或项目级
|
|
240
|
+
--config <path> 明确目标配置文件(格式由 Agent 决定)
|
|
241
|
+
--dry-run 仅预览脱敏计划,不写文件/执行程序
|
|
242
|
+
--replace 同名冲突时替换连接配置,保留宿主其它设置
|
|
243
|
+
--input-env key=ENV_NAME 将配置变量绑定到环境变量(可重复)
|
|
244
|
+
--materialize-inputs 显式将值写入用户私有配置;禁止项目级使用
|
|
245
|
+
--yes, -y 确认写入或执行测试(不会隐式启用 --replace)
|
|
246
|
+
--timeout <seconds> 测试总超时,默认 30,最大 300
|
|
247
|
+
--registry <url> 市场地址;查询公开目录,不发送账号 Token
|
|
248
|
+
--json 输出脱敏 JSON
|
|
249
|
+
|
|
250
|
+
示例:bfi mcp install bfi-db --agent codex --dry-run
|
|
251
|
+
bfi mcp install bfi-pm --agent codex --input-env pm_ak=PM_AK --input-env pm_sk=PM_SK --yes
|
|
252
|
+
bfi mcp test bfi-db --agent codex --yes
|
|
253
|
+
|
|
254
|
+
注册不安装依赖、不授予项目信任、不自动打开 OAuth。GUI Agent 必须能继承引用的环境变量。
|
|
255
|
+
stdio 测试会执行本机程序并使用本机登录态;远程测试会发送配置的认证头。`;
|
|
256
|
+
function parseMcpArguments(args) {
|
|
257
|
+
const result = {
|
|
258
|
+
command: args[0] ?? "help",
|
|
259
|
+
names: [],
|
|
260
|
+
scope: "user",
|
|
261
|
+
bindings: {},
|
|
262
|
+
dryRun: false,
|
|
263
|
+
replace: false,
|
|
264
|
+
materialize: false,
|
|
265
|
+
yes: false,
|
|
266
|
+
json: false,
|
|
267
|
+
timeoutMs: 3e4
|
|
268
|
+
};
|
|
269
|
+
if (["--help", "-h"].includes(result.command)) result.command = "help";
|
|
270
|
+
const seen = /* @__PURE__ */ new Set();
|
|
271
|
+
for (let index = 1; index < args.length; index++) {
|
|
272
|
+
const flag = args[index];
|
|
273
|
+
if (flag === "--help" || flag === "-h") {
|
|
274
|
+
result.command = "help";
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!flag.startsWith("-")) {
|
|
278
|
+
result.names.push(flag);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const normalized = flag === "-a" ? "--agent" : flag === "-y" ? "--yes" : flag === "--plan" ? "--dry-run" : flag;
|
|
282
|
+
if (seen.has(normalized) && normalized !== "--input-env") throw new Error("选项重复,请检查参数。");
|
|
283
|
+
seen.add(normalized);
|
|
284
|
+
if (normalized === "--yes") result.yes = true;
|
|
285
|
+
else if (normalized === "--dry-run") result.dryRun = true;
|
|
286
|
+
else if (normalized === "--replace") result.replace = true;
|
|
287
|
+
else if (normalized === "--json") result.json = true;
|
|
288
|
+
else if (normalized === "--materialize-inputs") result.materialize = true;
|
|
289
|
+
else {
|
|
290
|
+
if (![
|
|
291
|
+
"--agent",
|
|
292
|
+
"--scope",
|
|
293
|
+
"--config",
|
|
294
|
+
"--file",
|
|
295
|
+
"--registry",
|
|
296
|
+
"--input-env",
|
|
297
|
+
"--timeout"
|
|
298
|
+
].includes(normalized)) throw new Error("未知 MCP 选项;请运行 bfi mcp --help。");
|
|
299
|
+
const value = args[++index];
|
|
300
|
+
if (!value || value.startsWith("-")) throw new Error("选项缺少值;请运行 bfi mcp --help。");
|
|
301
|
+
if (normalized === "--scope") {
|
|
302
|
+
if (!["user", "project"].includes(value)) throw new Error("--scope 只接受 user 或 project。");
|
|
303
|
+
result.scope = value;
|
|
304
|
+
} else if (normalized === "--input-env") {
|
|
305
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
|
|
306
|
+
if (!match || Object.hasOwn(result.bindings, match[1])) throw new Error("--input-env 需要唯一的 key=ENV_NAME,不接受密钥值。");
|
|
307
|
+
if ([
|
|
308
|
+
"__proto__",
|
|
309
|
+
"constructor",
|
|
310
|
+
"prototype"
|
|
311
|
+
].includes(match[1])) throw new Error("不安全的变量名。");
|
|
312
|
+
result.bindings[match[1]] = match[2];
|
|
313
|
+
} else if (normalized === "--timeout") {
|
|
314
|
+
const timeout = Number(value);
|
|
315
|
+
if (!Number.isFinite(timeout) || timeout < .1 || timeout > 300) throw new Error("--timeout 范围为 0.1–300 秒。");
|
|
316
|
+
result.timeoutMs = timeout * 1e3;
|
|
317
|
+
} else result[normalized.slice(2)] = value;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (![
|
|
321
|
+
"help",
|
|
322
|
+
"search",
|
|
323
|
+
"info",
|
|
324
|
+
"agents",
|
|
325
|
+
"install",
|
|
326
|
+
"list",
|
|
327
|
+
"remove",
|
|
328
|
+
"test"
|
|
329
|
+
].includes(result.command)) throw new Error("未知 MCP 命令;请运行 bfi mcp --help。");
|
|
330
|
+
if (result.command === "help") return result;
|
|
331
|
+
if ([
|
|
332
|
+
"install",
|
|
333
|
+
"list",
|
|
334
|
+
"remove",
|
|
335
|
+
"test"
|
|
336
|
+
].includes(result.command) && !result.agent) throw new Error("请用 --agent 明确目标;bfi mcp agents 可查看支持列表。");
|
|
337
|
+
if (result.command !== "install" && (result.file || result.replace || result.materialize || Object.keys(result.bindings).length)) throw new Error("安装专用选项仅适用于 install。");
|
|
338
|
+
if (result.dryRun && !["install", "remove"].includes(result.command)) throw new Error("--dry-run 仅适用于 install/remove,不会执行测试。");
|
|
339
|
+
if ([
|
|
340
|
+
"info",
|
|
341
|
+
"remove",
|
|
342
|
+
"test"
|
|
343
|
+
].includes(result.command) && !result.names.length) throw new Error("请明确指定 MCP 名称。");
|
|
344
|
+
if (result.command === "info" && result.names.length !== 1) throw new Error("info 每次只接受一个 MCP 名称。");
|
|
345
|
+
if (["agents", "list"].includes(result.command) && result.names.length) throw new Error("此命令不接受 MCP 名称。");
|
|
346
|
+
if (result.command === "install" && !result.file && !result.names.length) throw new Error("请指定 MCP 名称或 --file。");
|
|
347
|
+
if (result.command !== "search") result.names.forEach(validName);
|
|
348
|
+
if (new Set(result.names).size !== result.names.length && result.command !== "search") throw new Error("MCP 名称重复。");
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
function terminalText(value) {
|
|
352
|
+
return value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, " ");
|
|
353
|
+
}
|
|
354
|
+
async function registryAddress(override) {
|
|
355
|
+
if (override) return override;
|
|
356
|
+
for (const path of [join(process.cwd(), ".bfirc"), join(homedir(), ".bfi/config.json")]) try {
|
|
357
|
+
const config = readDocument(await readFile(path, "utf8"), "jsonc");
|
|
358
|
+
if (typeof config.registry === "string" && config.registry) return config.registry;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error.code !== "ENOENT") throw new Error("BFI 配置无法读取;请检查配置或用 --registry 指定地址。");
|
|
361
|
+
}
|
|
362
|
+
return "https://bfi.yyuap.com:811";
|
|
363
|
+
}
|
|
364
|
+
async function confirm(yes, message) {
|
|
365
|
+
if (yes) return true;
|
|
366
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("非交互环境需要 --yes;安装可先使用 --dry-run。");
|
|
367
|
+
const prompt = createInterface({
|
|
368
|
+
input: process.stdin,
|
|
369
|
+
output: process.stdout
|
|
370
|
+
});
|
|
371
|
+
try {
|
|
372
|
+
return /^(y|yes)$/i.test((await prompt.question(`${message} [y/N] `)).trim());
|
|
373
|
+
} finally {
|
|
374
|
+
prompt.close();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function entryInfo(entry) {
|
|
378
|
+
return {
|
|
379
|
+
name: entry.registrationName,
|
|
380
|
+
title: entry.title,
|
|
381
|
+
description: entry.description,
|
|
382
|
+
transport: entry.config.type,
|
|
383
|
+
variables: variables(entry.config),
|
|
384
|
+
revision: entry.revision
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
async function runMcp(args) {
|
|
388
|
+
const options = parseMcpArguments(args);
|
|
389
|
+
const print = (value) => console.log(options.json ? JSON.stringify(value, null, 2) : terminalText(typeof value === "string" ? value : JSON.stringify(value)));
|
|
390
|
+
if (options.command === "help") {
|
|
391
|
+
console.log(help);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (options.command === "agents") {
|
|
395
|
+
const targets = agentIds.map((agent) => {
|
|
396
|
+
try {
|
|
397
|
+
const target = targetFor(agent, options.scope);
|
|
398
|
+
return {
|
|
399
|
+
agent,
|
|
400
|
+
path: target.path,
|
|
401
|
+
format: target.format
|
|
402
|
+
};
|
|
403
|
+
} catch {
|
|
404
|
+
return {
|
|
405
|
+
agent,
|
|
406
|
+
path: "需用 --config 明确文件;见帮助及 README"
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
if (options.json) print(targets);
|
|
411
|
+
else targets.forEach((target) => print(`${target.agent.padEnd(14)} ${target.path}`));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (options.command === "search" || options.command === "info") {
|
|
415
|
+
const registry = await registryAddress(options.registry);
|
|
416
|
+
const entries = options.command === "search" ? await readCatalog(registry, options.names.join(" ")) : await catalogEntries(registry, options.names);
|
|
417
|
+
if (options.json) print(entries.map(entryInfo));
|
|
418
|
+
else entries.forEach((entry) => print(`${entry.registrationName} [${entry.config.type}] ${entry.description ?? ""}${variables(entry.config).length ? ` · 变量:${variables(entry.config).join(", ")}` : ""}`));
|
|
419
|
+
if (!entries.length && !options.json) print("未找到 MCP。");
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const target = targetFor(options.agent, options.scope, { config: options.config });
|
|
423
|
+
if (options.command === "list") {
|
|
424
|
+
const { servers } = await loadTarget(target);
|
|
425
|
+
const rows = Object.entries(servers).map(([name, value]) => {
|
|
426
|
+
try {
|
|
427
|
+
return {
|
|
428
|
+
name,
|
|
429
|
+
transport: decodeServer(value, target).type
|
|
430
|
+
};
|
|
431
|
+
} catch {
|
|
432
|
+
return {
|
|
433
|
+
name,
|
|
434
|
+
transport: "原生扩展/停用配置,请在 Agent 中查看"
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
if (options.json) print({
|
|
439
|
+
path: target.path,
|
|
440
|
+
servers: rows
|
|
441
|
+
});
|
|
442
|
+
else {
|
|
443
|
+
print(target.path);
|
|
444
|
+
rows.forEach((row) => print(`${row.name} [${row.transport}]`));
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (options.command === "test") {
|
|
449
|
+
const { servers } = await loadTarget(target);
|
|
450
|
+
const configs = options.names.map((name) => {
|
|
451
|
+
if (!Object.hasOwn(servers, name)) throw new Error(`配置中没有 MCP:${name}。`);
|
|
452
|
+
return {
|
|
453
|
+
name,
|
|
454
|
+
config: decodeServer(servers[name], target)
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
if (!await confirm(options.yes, "测试会执行所选本机程序/连接远程服务,使用本机环境与登录态;只查询工具清单。继续?")) return;
|
|
458
|
+
const controller = new AbortController();
|
|
459
|
+
const abort = () => controller.abort();
|
|
460
|
+
process.once("SIGINT", abort);
|
|
461
|
+
process.once("SIGTERM", abort);
|
|
462
|
+
const results = [];
|
|
463
|
+
try {
|
|
464
|
+
for (const item of configs) {
|
|
465
|
+
if (controller.signal.aborted) break;
|
|
466
|
+
const result = await probe(item.config, {
|
|
467
|
+
timeoutMs: options.timeoutMs,
|
|
468
|
+
signal: controller.signal,
|
|
469
|
+
autoSse: ["cursor", "opencode"].includes(target.agent)
|
|
470
|
+
});
|
|
471
|
+
results.push({
|
|
472
|
+
name: item.name,
|
|
473
|
+
...result
|
|
474
|
+
});
|
|
475
|
+
if (!result.ok) process.exitCode = 1;
|
|
476
|
+
if (!options.json) print(result.ok ? `${item.name}:连接成功,发现 ${result.tools.length} 个工具(未调用业务工具)。` : `${item.name}:${result.message} [${result.phase} / ${result.code}]`);
|
|
477
|
+
}
|
|
478
|
+
if (controller.signal.aborted) process.exitCode = 130;
|
|
479
|
+
if (options.json) print({
|
|
480
|
+
path: target.path,
|
|
481
|
+
results,
|
|
482
|
+
cancelled: controller.signal.aborted
|
|
483
|
+
});
|
|
484
|
+
} finally {
|
|
485
|
+
process.removeListener("SIGINT", abort);
|
|
486
|
+
process.removeListener("SIGTERM", abort);
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
let entries = [];
|
|
491
|
+
if (options.command === "install") {
|
|
492
|
+
if (options.file) {
|
|
493
|
+
const data = await readConfig(options.file);
|
|
494
|
+
if (data === void 0) throw new Error("导入配置文件不存在。");
|
|
495
|
+
entries = entriesFromJson(readDocument(data, "jsonc"));
|
|
496
|
+
if (options.names.length) entries = options.names.map((name) => {
|
|
497
|
+
const match = entries.find((entry) => entry.registrationName === name);
|
|
498
|
+
if (!match) throw new Error(`导入文件中没有 MCP:${name}。`);
|
|
499
|
+
return match;
|
|
500
|
+
});
|
|
501
|
+
} else entries = await catalogEntries(await registryAddress(options.registry), options.names);
|
|
502
|
+
}
|
|
503
|
+
const plan = options.command === "remove" ? await planRemove(target, options.names) : await planInstall(target, entries, {
|
|
504
|
+
replace: options.replace,
|
|
505
|
+
bindings: options.bindings,
|
|
506
|
+
materialize: options.materialize
|
|
507
|
+
});
|
|
508
|
+
const warnings = [];
|
|
509
|
+
if (entries.some((entry) => builtinCommands.has(entry.config.command ?? ""))) warnings.push("BFI 命令复用 bfi auth login 登录态;本命令不安装/升级 bfi-cli,也不额外写入 BFI_TOKEN。");
|
|
510
|
+
if (entries.some((entry) => variables(entry.config).length) && !options.materialize) warnings.push("只保存变量引用。请安全设置环境变量,并确保实际启动 Agent 的进程可读取(GUI 不一定继承终端环境)。");
|
|
511
|
+
if (options.materialize) warnings.push("将把真实值写入用户私有配置及其后续备份;请勿分享这些文件。");
|
|
512
|
+
if (target.scope === "project") warnings.push("项目配置不自动获得宿主信任;请在 Agent 中审核启用。");
|
|
513
|
+
const summary = {
|
|
514
|
+
agent: target.agent,
|
|
515
|
+
path: target.path,
|
|
516
|
+
scope: target.scope,
|
|
517
|
+
changes: plan.changes,
|
|
518
|
+
warnings
|
|
519
|
+
};
|
|
520
|
+
if (!options.json) {
|
|
521
|
+
print(`目标:${target.agent} · ${target.scope} · ${target.path}`);
|
|
522
|
+
plan.changes.forEach((change) => print(`${change.action.padEnd(10)} ${change.name}${change.variables?.length ? ` · 环境变量 ${change.variables.join(", ")}` : ""}`));
|
|
523
|
+
warnings.forEach(print);
|
|
524
|
+
}
|
|
525
|
+
if (options.dryRun) {
|
|
526
|
+
if (options.json) print({
|
|
527
|
+
...summary,
|
|
528
|
+
dryRun: true
|
|
529
|
+
});
|
|
530
|
+
else print("仅预览,未写入配置。");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (plan.next === plan.previous) {
|
|
534
|
+
if (options.json) print({
|
|
535
|
+
...summary,
|
|
536
|
+
changed: false
|
|
537
|
+
});
|
|
538
|
+
else print("无需修改。");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!await confirm(options.yes, "按以上计划修改配置?")) {
|
|
542
|
+
if (options.json) print({
|
|
543
|
+
...summary,
|
|
544
|
+
cancelled: true
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const result = await applyPlan(plan);
|
|
549
|
+
if (options.json) print({
|
|
550
|
+
...summary,
|
|
551
|
+
...result
|
|
552
|
+
});
|
|
553
|
+
else {
|
|
554
|
+
print(`已更新配置:${target.path}`);
|
|
555
|
+
if (result.backup) print(`原配置备份:${result.backup}`);
|
|
556
|
+
print("重新加载/重启目标 Agent 后生效。");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
export { runMcp };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const variablePattern = /\$\{(?:user_config\.)?([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
2
|
+
const builtinCommands = /* @__PURE__ */ new Set([
|
|
3
|
+
"bfi-db-mcp",
|
|
4
|
+
"bfi-codesearch-mcp",
|
|
5
|
+
"bfi-weknora-mcp"
|
|
6
|
+
]);
|
|
7
|
+
function object(value, label = "配置") {
|
|
8
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}必须为对象。`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function validName(name) {
|
|
12
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(name) || [
|
|
13
|
+
"__proto__",
|
|
14
|
+
"prototype",
|
|
15
|
+
"constructor"
|
|
16
|
+
].includes(name)) throw new Error("MCP 名称只能包含字母、数字、下划线、点或连字符(最长 128 字符)。");
|
|
17
|
+
return name;
|
|
18
|
+
}
|
|
19
|
+
function stringMap(value, label) {
|
|
20
|
+
if (value === void 0) return;
|
|
21
|
+
const mapping = object(value, label);
|
|
22
|
+
for (const [key, item] of Object.entries(mapping)) {
|
|
23
|
+
if (!key || [
|
|
24
|
+
"__proto__",
|
|
25
|
+
"prototype",
|
|
26
|
+
"constructor"
|
|
27
|
+
].includes(key) || typeof item !== "string" || /[\0\r\n]/.test(key)) throw new Error(`${label}必须是字符串键值映射。`);
|
|
28
|
+
if (item.includes("\0")) throw new Error("配置值不允许 NUL 字符。");
|
|
29
|
+
if (label === "headers" && /[\r\n]/.test(item)) throw new Error("HTTP header 不允许换行。");
|
|
30
|
+
if (label === "env" && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error("环境变量名无效。");
|
|
31
|
+
if (label === "headers" && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(key)) throw new Error("HTTP header 名称无效。");
|
|
32
|
+
}
|
|
33
|
+
return Object.keys(mapping).length ? mapping : void 0;
|
|
34
|
+
}
|
|
35
|
+
function normalizeConfig(value, resolved = false) {
|
|
36
|
+
const source = object(value);
|
|
37
|
+
const allowed = [
|
|
38
|
+
"type",
|
|
39
|
+
"transport",
|
|
40
|
+
"command",
|
|
41
|
+
"args",
|
|
42
|
+
"env",
|
|
43
|
+
"cwd",
|
|
44
|
+
"url",
|
|
45
|
+
"headers"
|
|
46
|
+
];
|
|
47
|
+
if (Object.keys(source).some((key) => !allowed.includes(key))) throw new Error("配置含未支持的扩展字段;为避免丢失其语义,本次不安装。请使用客户端原生配置。");
|
|
48
|
+
if (source.type && source.transport && source.type !== source.transport) throw new Error("type 与 transport 冲突。");
|
|
49
|
+
const rawType = source.type ?? source.transport ?? (source.command ? "stdio" : "http");
|
|
50
|
+
const type = rawType === "streamable-http" ? "http" : rawType;
|
|
51
|
+
if (![
|
|
52
|
+
"stdio",
|
|
53
|
+
"http",
|
|
54
|
+
"sse"
|
|
55
|
+
].includes(String(type))) throw new Error("不支持的 MCP 传输方式。");
|
|
56
|
+
const result = { type };
|
|
57
|
+
if (type === "stdio") {
|
|
58
|
+
if (typeof source.command !== "string" || !source.command.trim() || /[\0\r\n]/.test(source.command)) throw new Error("stdio 需要有效的 command。");
|
|
59
|
+
if (source.url !== void 0 || source.headers !== void 0) throw new Error("stdio 不接受 url/headers。");
|
|
60
|
+
result.command = source.command;
|
|
61
|
+
if (source.args !== void 0) {
|
|
62
|
+
if (!Array.isArray(source.args) || !source.args.every((item) => typeof item === "string" && !item.includes("\0"))) throw new Error("args 必须为字符串数组。");
|
|
63
|
+
if (source.args.length) result.args = source.args;
|
|
64
|
+
}
|
|
65
|
+
const env = stringMap(source.env, "env");
|
|
66
|
+
if (env) result.env = env;
|
|
67
|
+
if (source.cwd !== void 0) {
|
|
68
|
+
if (typeof source.cwd !== "string" || !source.cwd || source.cwd.includes("\0")) throw new Error("cwd 必须为路径字符串。");
|
|
69
|
+
result.cwd = source.cwd;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
if ([
|
|
73
|
+
"command",
|
|
74
|
+
"args",
|
|
75
|
+
"env",
|
|
76
|
+
"cwd"
|
|
77
|
+
].some((key) => source[key] !== void 0)) throw new Error("远程 MCP 不接受 stdio 启动字段。");
|
|
78
|
+
if (typeof source.url !== "string") throw new Error("远程 MCP 需要 url。");
|
|
79
|
+
let url;
|
|
80
|
+
try {
|
|
81
|
+
url = new URL(source.url.replace(variablePattern, "placeholder"));
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error("MCP URL 无效。");
|
|
84
|
+
}
|
|
85
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) throw new Error("MCP URL 必须是无内嵌凭据的 HTTP(S) 地址。");
|
|
86
|
+
result.url = source.url;
|
|
87
|
+
const headers = stringMap(source.headers, "headers");
|
|
88
|
+
if (headers) result.headers = headers;
|
|
89
|
+
}
|
|
90
|
+
for (const value of resolved ? [] : strings(result)) if (value.replace(variablePattern, "").includes("${")) throw new Error("不支持的变量语法;请使用 ${NAME} 或 ${user_config.NAME}。");
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
function strings(value) {
|
|
94
|
+
if (typeof value === "string") return [value];
|
|
95
|
+
if (Array.isArray(value)) return value.flatMap(strings);
|
|
96
|
+
if (value && typeof value === "object") return Object.values(value).flatMap(strings);
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
function mapStrings(value, mapper) {
|
|
100
|
+
if (typeof value === "string") return mapper(value);
|
|
101
|
+
if (Array.isArray(value)) return value.map((item) => mapStrings(item, mapper));
|
|
102
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapStrings(item, mapper)]));
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
function variables(config) {
|
|
106
|
+
return [...new Set(strings(config).flatMap((value) => [...value.matchAll(variablePattern)].map((match) => match[1])))];
|
|
107
|
+
}
|
|
108
|
+
function resolveVariables(config, bindings, environment) {
|
|
109
|
+
return normalizeConfig(mapStrings(config, (text) => text.replace(variablePattern, (_whole, name, fallback) => {
|
|
110
|
+
const key = bindings[name] ?? name;
|
|
111
|
+
const value = environment[key] || fallback;
|
|
112
|
+
if (value === void 0 || value === "") throw new Error(`缺少环境变量 ${key},请在本机安全设置后重试。`);
|
|
113
|
+
return value;
|
|
114
|
+
})), true);
|
|
115
|
+
}
|
|
116
|
+
function entriesFromJson(value) {
|
|
117
|
+
const mapping = object(object(value).mcpServers, "mcpServers");
|
|
118
|
+
if (!Object.keys(mapping).length || Object.keys(mapping).length > 100) throw new Error("mcpServers 需要 1–100 项。");
|
|
119
|
+
return Object.entries(mapping).map(([name, config]) => ({
|
|
120
|
+
registrationName: validName(name),
|
|
121
|
+
config: normalizeConfig(config)
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
export { builtinCommands, entriesFromJson, mapStrings, normalizeConfig, object, resolveVariables, validName, variablePattern, variables };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { pathKey, receipts } from "./receipt.mjs";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
async function assertLegacyUnmanaged(paths, home) {
|
|
4
|
+
const owned = (await receipts(home)).flatMap((record) => record.actions.filter((action) => action.kind === "skill" && !action.remove).map((action) => pathKey(action.path)));
|
|
5
|
+
if (paths.some((path) => owned.some((root) => root === pathKey(path) || root.startsWith(pathKey(path) + sep) || pathKey(path).startsWith(root + sep)))) throw new Error("此技能属于完整安装回执;请使用 bfi skills/package update 或 package uninstall @空间/名称,不可通过旧批量路径覆盖或删除。");
|
|
6
|
+
}
|
|
7
|
+
export { assertLegacyUnmanaged };
|